add c# files

- Move from rather ad-hoc, error-prone resource management to IDisposable, which should give us a bit more enforcement.
- Optimistically remove "buggy" from readme because the known bugs are now fixed! The main source of bugs was the incorrect InitializeProcThreadAttributeList usage.
- Handle ctrl-c by forwarding it to the PseudoConsole
- Handle terminal close when the window close button is used
- Use .NET's CopyTo in the CopyPipeToOutput, it's much simpler code and seems more robust than the ReadFile/WriteFile approach
- Minor refactor to split native APIs to multiple files
This commit is contained in:
Will Fuqua 2018-09-20 22:13:37 +07:00
parent e09359138e
commit 637c57473e
No known key found for this signature in database
GPG Key ID: 67B6F489167C16A3
5 changed files with 390 additions and 0 deletions

View File

@ -0,0 +1,133 @@
using System;
using System.Runtime.InteropServices;
using static MiniTerm.Native.ProcessApi;
namespace MiniTerm
{
/// <summary>
/// Support for starting and configuring processes.
/// </summary>
/// <remarks>
/// Possible to replace with managed code? The key is being able to provide the PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE attribute
/// </remarks>
static class Process
{
/// <summary>
/// Start and configure a process. The return value should be considered opaque, and eventually disposed.
/// </summary>
internal static ProcessResources Start(IntPtr hPC, string command, IntPtr attributes)
{
var startupInfo = ConfigureProcessThread(hPC, attributes);
var processInfo = RunProcess(ref startupInfo, "cmd.exe");
return new ProcessResources(startupInfo, processInfo);
}
private static STARTUPINFOEX ConfigureProcessThread(IntPtr hPC, IntPtr attributes)
{
var lpSize = IntPtr.Zero;
var success = InitializeProcThreadAttributeList(
lpAttributeList: IntPtr.Zero,
dwAttributeCount: 1,
dwFlags: 0,
lpSize: ref lpSize
);
if (success || lpSize == IntPtr.Zero) // we're not expecting `success` here, we just want to get the calculated lpSize
{
throw new InvalidOperationException("Could not calculate the number of bytes for the attribute list. " + Marshal.GetLastWin32Error());
}
var startupInfo = new STARTUPINFOEX();
startupInfo.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
startupInfo.lpAttributeList = Marshal.AllocHGlobal(lpSize);
success = InitializeProcThreadAttributeList(
lpAttributeList: startupInfo.lpAttributeList,
dwAttributeCount: 1,
dwFlags: 0,
lpSize: ref lpSize
);
if (!success)
{
throw new InvalidOperationException("Could not set up attribute list. " + Marshal.GetLastWin32Error());
}
success = UpdateProcThreadAttribute(
lpAttributeList: startupInfo.lpAttributeList,
dwFlags: 0,
attribute: attributes,
lpValue: hPC,
cbSize: (IntPtr)IntPtr.Size,
lpPreviousValue: IntPtr.Zero,
lpReturnSize: IntPtr.Zero
);
if (!success)
{
throw new InvalidOperationException("Could not set pseudoconsole thread attribute. " + Marshal.GetLastWin32Error());
}
return startupInfo;
}
private static PROCESS_INFORMATION RunProcess(ref STARTUPINFOEX sInfoEx, string commandLine)
{
int securityAttributeSize = Marshal.SizeOf<SECURITY_ATTRIBUTES>();
var pSec = new SECURITY_ATTRIBUTES { nLength = securityAttributeSize };
var tSec = new SECURITY_ATTRIBUTES { nLength = securityAttributeSize };
var success = CreateProcess(
lpApplicationName: null,
lpCommandLine: commandLine,
lpProcessAttributes: ref pSec,
lpThreadAttributes: ref tSec,
bInheritHandles: false,
dwCreationFlags: EXTENDED_STARTUPINFO_PRESENT,
lpEnvironment: IntPtr.Zero,
lpCurrentDirectory: null,
lpStartupInfo: ref sInfoEx,
lpProcessInformation: out PROCESS_INFORMATION pInfo
);
if (!success)
{
throw new InvalidOperationException("Could not create process. " + Marshal.GetLastWin32Error());
}
return pInfo;
}
private static void CleanUp(STARTUPINFOEX startupInfo, PROCESS_INFORMATION processInfo)
{
// Free the attribute list
if (startupInfo.lpAttributeList != IntPtr.Zero)
{
DeleteProcThreadAttributeList(startupInfo.lpAttributeList);
Marshal.FreeHGlobal(startupInfo.lpAttributeList);
}
// Close process and thread handles
if (processInfo.hProcess != IntPtr.Zero)
{
CloseHandle(processInfo.hProcess);
}
if (processInfo.hThread != IntPtr.Zero)
{
CloseHandle(processInfo.hThread);
}
}
internal sealed class ProcessResources : IDisposable
{
public ProcessResources(STARTUPINFOEX startupInfo, PROCESS_INFORMATION processInfo)
{
StartupInfo = startupInfo;
ProcessInfo = processInfo;
}
STARTUPINFOEX StartupInfo { get; }
PROCESS_INFORMATION ProcessInfo { get; }
public void Dispose()
{
CleanUp(StartupInfo, ProcessInfo);
}
}
}
}

View File

@ -0,0 +1,35 @@
using System;
namespace MiniTerm
{
/// <summary>
/// C# version of:
/// https://blogs.msdn.microsoft.com/commandline/2018/08/02/windows-command-line-introducing-the-windows-pseudo-console-conpty/
/// https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session
///
/// System Requirements:
/// As of September 2018, requires Windows 10 with the "Windows Insider Program" installed for Redstone 5.
/// Also requires the Windows Insider Preview SDK: https://www.microsoft.com/en-us/software-download/windowsinsiderpreviewSDK
/// </summary>
/// <remarks>
/// Basic design is:
/// Terminal UI starts the PseudoConsole, and controls it using a pair of PseudoConsolePipes
/// Terminal UI will run the Process (cmd.exe) and associate it with the PseudoConsole.
/// </remarks>
static class Program
{
static void Main(string[] args)
{
try
{
var terminal = new Terminal();
terminal.Run("cmd.exe");
}
catch (InvalidOperationException e)
{
Console.Error.WriteLine(e.Message);
throw;
}
}
}
}

View File

@ -0,0 +1,39 @@
using Microsoft.Win32.SafeHandles;
using System;
using static MiniTerm.Native.PseudoConsoleApi;
namespace MiniTerm
{
/// <summary>
/// Utility functions around the new Pseudo Console APIs
/// </summary>
internal sealed class PseudoConsole : IDisposable
{
public static readonly IntPtr PseudoConsoleThreadAttribute = (IntPtr)PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE;
public IntPtr Handle { get; }
private PseudoConsole(IntPtr handle)
{
this.Handle = handle;
}
internal static PseudoConsole Create(SafeFileHandle inputReadSide, SafeFileHandle outputWriteSide, int width, int height)
{
var createResult = CreatePseudoConsole(
new COORD { X = (short)width, Y = (short)height },
inputReadSide, outputWriteSide,
0, out IntPtr hPC);
if(createResult != 0)
{
throw new InvalidOperationException("Could not create psuedo console. Error Code " + createResult);
}
return new PseudoConsole(hPC);
}
public void Dispose()
{
ClosePseudoConsole(Handle);
}
}
}

View File

@ -0,0 +1,33 @@
using Microsoft.Win32.SafeHandles;
using System;
using static MiniTerm.Native.PseudoConsoleApi;
namespace MiniTerm
{
/// <summary>
/// A pipe used to talk to the pseudoconsole, as described in:
/// https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session
/// </summary>
/// <remarks>
/// We'll have two instances of this class, one for input and one for output.
/// </remarks>
internal sealed class PseudoConsolePipe : IDisposable
{
public SafeFileHandle ReadSide;
public SafeFileHandle WriteSide;
public PseudoConsolePipe()
{
if (!CreatePipe(out ReadSide, out WriteSide, IntPtr.Zero, 0))
{
throw new InvalidOperationException("failed to create pipe");
}
}
public void Dispose()
{
ReadSide.Dispose();
WriteSide.Dispose();
}
}
}

View File

@ -0,0 +1,150 @@
using Microsoft.Win32.SafeHandles;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using static MiniTerm.Native.ConsoleApi;
namespace MiniTerm
{
/// <summary>
/// The UI of the terminal. It's just a normal console window, but we're managing the input/output.
/// In a "real" project this could be some other UI.
/// </summary>
internal sealed class Terminal
{
private const string ExitCommand = "exit\r";
private const string CtrlC_Command = "\x3";
public Terminal()
{
EnableVirtualTerminalSequenceProcessing();
}
/// <summary>
/// Newer versions of the windows console support interpreting virtual terminal sequences, we just have to opt-in
/// </summary>
private static void EnableVirtualTerminalSequenceProcessing()
{
var iStdOut = GetStdHandle(STD_OUTPUT_HANDLE);
if (!GetConsoleMode(iStdOut, out uint outConsoleMode))
{
throw new InvalidOperationException("Could not get console mode");
}
outConsoleMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN;
if (!SetConsoleMode(iStdOut, outConsoleMode))
{
throw new InvalidOperationException("Could not enable virtual terminal processing");
}
}
/// <summary>
/// Start the psuedoconsole and run the process as shown in
/// https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session#creating-the-pseudoconsole
/// </summary>
/// <param name="command">the command to run, e.g. cmd.exe</param>
public void Run(string command)
{
using (var inputPipe = new PseudoConsolePipe())
using (var outputPipe = new PseudoConsolePipe())
using (var pseudoConsole = PseudoConsole.Create(inputPipe.ReadSide, outputPipe.WriteSide, Console.WindowWidth, Console.WindowHeight))
using (var process = Process.Start(pseudoConsole.Handle, command, PseudoConsole.PseudoConsoleThreadAttribute))
{
// set up a background task to copy all pseudoconsole output to stdout
Task.Run(() => CopyPipeToOutput(outputPipe.ReadSide));
// free resources in case the console is ungracefully closed (e.g. by the 'x' in the window titlebar)
OnClose(() => DisposeResources(process, pseudoConsole, outputPipe, inputPipe));
// prompt for stdin input and send the result to the pipe.
// blocks until the user types "exit"
CopyInputToPipe(inputPipe.WriteSide);
}
}
/// <summary>
/// Reads terminal input and copies it to the PseudoConsole
/// </summary>
/// <param name="inputWriteSide">the "write" side of the pseudo console input pipe</param>
private static void CopyInputToPipe(SafeFileHandle inputWriteSide)
{
using (var writer = new StreamWriter(new FileStream(inputWriteSide, FileAccess.Write)))
{
ForwardCtrlC(writer);
writer.AutoFlush = true;
writer.WriteLine(@"cd \");
StringBuilder buffer = new StringBuilder();
while (true)
{
// send input character-by-character to the pipe
char key = Console.ReadKey(intercept: true).KeyChar;
writer.Write(key);
// stop the input loop if 'exit' was sent
// TODO: if we have nested cmd.exe process, this will kill all of them which is wrong.
// could we somehow detect when the top-level cmd.exe process has ended and remove this logic?
buffer.Append(key);
if (key == '\r')
{
if (buffer.ToString() == ExitCommand)
{
break;
}
buffer.Clear();
}
}
}
}
/// <summary>
/// Don't let ctrl-c kill the terminal, it should be sent to the process in the terminal.
/// </summary>
private static void ForwardCtrlC(StreamWriter writer)
{
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true;
writer.Write(CtrlC_Command);
};
}
/// <summary>
/// Reads PseudoConsole output and copies it to the terminal's standard out.
/// </summary>
/// <param name="outputReadSide">the "read" side of the pseudo console output pipe</param>
private static void CopyPipeToOutput(SafeFileHandle outputReadSide)
{
using (var terminalOutput = Console.OpenStandardOutput())
using (var pseudoConsoleOutput = new FileStream(outputReadSide, FileAccess.Read))
{
pseudoConsoleOutput.CopyTo(terminalOutput);
}
}
/// <summary>
/// Set a callback for when the terminal is closed (e.g. via the "X" window decoration button).
/// Intended for resource cleanup logic.
/// </summary>
private static void OnClose(Action handler)
{
SetConsoleCtrlHandler(eventType =>
{
if(eventType == CtrlTypes.CTRL_CLOSE_EVENT)
{
handler();
}
return false;
}, true);
}
private void DisposeResources(params IDisposable[] disposables)
{
foreach (var disposable in disposables)
{
disposable.Dispose();
}
}
}
}