Terminal/SRxConPty.psm1
|
<# SRxConPty.psm1 (Terminal subsystem — Iteration 5) ------------------------------------------------------------------------ Native Windows Pseudo Console (ConPTY) interop + a self-contained session object that: - launches a target executable (powershell.exe by default) headless, attached only to a ConPTY handle pair (no HWND is ever created) - reads child output on a dedicated background thread (blocking ReadFile loop on the anonymous pipe) - writes input asynchronously via ThreadPool work items (never blocks the caller / UI thread) - exposes received output via a thread-safe ConcurrentQueue<byte[]>, polled by the WinForms host on a UI-thread Timer (see GOTCHA #4 in SRxTerminalBoxControl.psm1's header for why this queue+poll design replaces a naive "raise a .NET event, invoke a PowerShell scriptblock delegate from this background thread" approach) - the legacy DataReceived/ProcessExited .NET events are still raised too, for any non-PowerShell/C# consumer, but PowerShell callers should prefer TryDequeueOutput()/HasExited/LastExitCode polling instead. Mirrors the Property Box / Filter Box convention: all native plumbing is isolated in ITS OWN module, imported by the *.Control.psm1, never by Explorer.ps1 directly. ------------------------------------------------------------------------ #> #region ---- native ConPTY + process-creation plumbing (C#) ---------------- $script:SRxConPtyCSharp = @' using System; using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; using System.Threading; namespace SRx.Terminal { [StructLayout(LayoutKind.Sequential)] internal struct COORD { public short X; public short Y; public COORD(short x, short y) { X = x; Y = y; } } [StructLayout(LayoutKind.Sequential)] internal struct STARTUPINFO { public int cb; public IntPtr lpReserved; public IntPtr lpDesktop; public IntPtr lpTitle; public int dwX; public int dwY; public int dwXSize; public int dwYSize; public int dwXCountChars; public int dwYCountChars; public int dwFillAttribute; public int dwFlags; public short wShowWindow; public short cbReserved2; public IntPtr lpReserved2; public IntPtr hStdInput; public IntPtr hStdOutput; public IntPtr hStdError; } [StructLayout(LayoutKind.Sequential)] internal struct STARTUPINFOEX { public STARTUPINFO StartupInfo; public IntPtr lpAttributeList; } [StructLayout(LayoutKind.Sequential)] internal struct PROCESS_INFORMATION { public IntPtr hProcess; public IntPtr hThread; public int dwProcessId; public int dwThreadId; } internal static class NativeMethods { internal const int PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016; internal const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000; internal const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400; internal const uint CREATE_NO_WINDOW = 0x08000000; [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool CreatePipe(out IntPtr hReadPipe, out IntPtr hWritePipe, IntPtr lpPipeAttributes, uint nSize); [DllImport("kernel32.dll")] internal static extern int CreatePseudoConsole(COORD size, IntPtr hInput, IntPtr hOutput, uint dwFlags, out IntPtr phPC); [DllImport("kernel32.dll")] internal static extern int ResizePseudoConsole(IntPtr hPC, COORD size); [DllImport("kernel32.dll")] internal static extern void ClosePseudoConsole(IntPtr hPC); [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref IntPtr lpSize); [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize); [DllImport("kernel32.dll")] internal static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList); [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern bool CreateProcess( string lpApplicationName, StringBuilder lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool ReadFile(IntPtr hFile, byte[] lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, IntPtr lpOverlapped); [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool WriteFile(IntPtr hFile, byte[] lpBuffer, uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten, IntPtr lpOverlapped); [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool CloseHandle(IntPtr hObject); [DllImport("kernel32.dll")] internal static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode); [DllImport("kernel32.dll")] internal static extern IntPtr GetModuleHandle(string lpModuleName); [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] internal static extern IntPtr GetProcAddress(IntPtr hModule, string procName); } /// <summary> /// True Win32 ConPTY-backed session: launches a target command line headless, /// attached to a real pseudo console. Output is exposed via a thread-safe /// ConcurrentQueue (TryDequeueOutput) intended to be POLLED from a UI-thread /// Timer, rather than consumed via the legacy DataReceived/ProcessExited /// events (which fire on this class's own background threads and are /// unsafe to handle directly with a PowerShell-scriptblock-based delegate - /// see SRxTerminalBoxControl.psm1 header, GOTCHA #4). /// </summary> public sealed class ConPtySession : IDisposable { private IntPtr _hPC = IntPtr.Zero; private IntPtr _hProcess = IntPtr.Zero; private IntPtr _hThread = IntPtr.Zero; private IntPtr _hInputWrite = IntPtr.Zero; // our end: write child's stdin private IntPtr _hOutputRead = IntPtr.Zero; // our end: read child's stdout private IntPtr _attrListPtr = IntPtr.Zero; private Thread _readerThread; private volatile bool _running; private int _processId = -1; private readonly ConcurrentQueue<byte[]> _outputQueue = new ConcurrentQueue<byte[]>(); private volatile bool _hasExited = false; private volatile int _lastExitCode = -1; /// <summary>Legacy direct-callback events. Fire on THIS CLASS'S OWN background /// threads (reader / waiter) - NOT the UI thread. Safe for C#/.NET consumers /// that marshal appropriately themselves; PowerShell callers should prefer /// polling TryDequeueOutput()/HasExited/LastExitCode from a UI-thread Timer /// instead (see module header).</summary> public event Action<byte[]> DataReceived; public event Action<int> ProcessExited; public bool IsRunning { get { return _running; } } public int ProcessId { get { return _processId; } } public bool HasExited { get { return _hasExited; } } public int LastExitCode { get { return _lastExitCode; } } /// <summary>Poll-friendly, thread-safe: dequeues one chunk of raw output /// bytes if available. Call this repeatedly from a UI-thread Timer.</summary> public bool TryDequeueOutput(out byte[] chunk) { return _outputQueue.TryDequeue(out chunk); } /// <summary> /// Returns true if this Windows build exposes CreatePseudoConsole (build 17763+). /// </summary> public static bool IsSupported() { try { IntPtr hMod = NativeMethods.GetModuleHandle("kernel32.dll"); if (hMod == IntPtr.Zero) { return false; } return NativeMethods.GetProcAddress(hMod, "CreatePseudoConsole") != IntPtr.Zero; } catch { return false; } } public void Start(string commandLine, string workingDirectory, int cols, int rows) { if (_running) { throw new InvalidOperationException("ConPtySession already started."); } IntPtr hInputRead = IntPtr.Zero, hInputWrite = IntPtr.Zero; IntPtr hOutputRead = IntPtr.Zero, hOutputWrite = IntPtr.Zero; try { // Pipe pair #1: child's STDIN. We keep the WRITE side, child gets the READ side. if (!NativeMethods.CreatePipe(out hInputRead, out hInputWrite, IntPtr.Zero, 0)) { ThrowLastError("CreatePipe(stdin)"); } // Pipe pair #2: child's STDOUT/STDERR. We keep the READ side, child gets the WRITE side. if (!NativeMethods.CreatePipe(out hOutputRead, out hOutputWrite, IntPtr.Zero, 0)) { ThrowLastError("CreatePipe(stdout)"); } COORD size = new COORD((short)Math.Max(1, cols), (short)Math.Max(1, rows)); int hr = NativeMethods.CreatePseudoConsole(size, hInputRead, hOutputWrite, 0, out _hPC); if (hr != 0) { throw new InvalidOperationException(string.Format("CreatePseudoConsole failed, HRESULT=0x{0:X8}", hr)); } // The conpty duplicates these internally; our copies must be closed now. NativeMethods.CloseHandle(hInputRead); NativeMethods.CloseHandle(hOutputWrite); hInputRead = IntPtr.Zero; hOutputWrite = IntPtr.Zero; _hInputWrite = hInputWrite; _hOutputRead = hOutputRead; CreateChildProcess(commandLine, workingDirectory); _running = true; _readerThread = new Thread(ReaderLoop); _readerThread.IsBackground = true; _readerThread.Name = "SRxConPty-Reader"; _readerThread.Start(); Thread waiter = new Thread(WaitForExitLoop); waiter.IsBackground = true; waiter.Name = "SRxConPty-Waiter"; waiter.Start(); } catch { if (hInputRead != IntPtr.Zero) { NativeMethods.CloseHandle(hInputRead); } if (hOutputWrite != IntPtr.Zero) { NativeMethods.CloseHandle(hOutputWrite); } Cleanup(); throw; } } private void CreateChildProcess(string commandLine, string workingDirectory) { IntPtr attrSize = IntPtr.Zero; NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attrSize); _attrListPtr = Marshal.AllocHGlobal(attrSize); if (!NativeMethods.InitializeProcThreadAttributeList(_attrListPtr, 1, 0, ref attrSize)) { ThrowLastError("InitializeProcThreadAttributeList"); } if (!NativeMethods.UpdateProcThreadAttribute( _attrListPtr, 0, (IntPtr)NativeMethods.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, _hPC, (IntPtr)IntPtr.Size, IntPtr.Zero, IntPtr.Zero)) { ThrowLastError("UpdateProcThreadAttribute"); } STARTUPINFOEX si = new STARTUPINFOEX(); si.StartupInfo.cb = Marshal.SizeOf(typeof(STARTUPINFOEX)); si.lpAttributeList = _attrListPtr; PROCESS_INFORMATION pi; StringBuilder cmd = new StringBuilder(commandLine); uint flags = NativeMethods.EXTENDED_STARTUPINFO_PRESENT | NativeMethods.CREATE_UNICODE_ENVIRONMENT; bool ok = NativeMethods.CreateProcess( null, cmd, IntPtr.Zero, IntPtr.Zero, false, flags, IntPtr.Zero, string.IsNullOrEmpty(workingDirectory) ? null : workingDirectory, ref si, out pi); if (!ok) { ThrowLastError("CreateProcess"); } _hProcess = pi.hProcess; _hThread = pi.hThread; _processId = pi.dwProcessId; } private void ReaderLoop() { byte[] buffer = new byte[8192]; try { while (_running) { uint bytesRead; bool ok = NativeMethods.ReadFile(_hOutputRead, buffer, (uint)buffer.Length, out bytesRead, IntPtr.Zero); if (!ok || bytesRead == 0) { break; // ERROR_BROKEN_PIPE (child exited / pty closed) or handle closed by Stop() } byte[] chunk = new byte[bytesRead]; Buffer.BlockCopy(buffer, 0, chunk, 0, (int)bytesRead); // Primary, safe path: enqueue for polling by a UI-thread Timer. _outputQueue.Enqueue(chunk); // Legacy direct-callback path, best-effort only: if a consumer // subscribed a delegate that isn't safe to invoke from this // background thread (e.g. a PowerShell scriptblock without a // runspace on this thread), swallow the failure here so it // can never disrupt the reader loop or the queue above. try { Action<byte[]> handler = DataReceived; if (handler != null) { handler(chunk); } } catch { } } } catch { // handle closed out from under us during Stop() — expected, swallow. } finally { RaiseExitedOnce(); } } private void WaitForExitLoop() { if (_hProcess == IntPtr.Zero) { return; } NativeMethods.WaitForSingleObject(_hProcess, 0xFFFFFFFF /* INFINITE */); RaiseExitedOnce(); } private int _exitedRaised; // 0/1 guard so DataReceived-EOF and WaitForExit don't double-fire private void RaiseExitedOnce() { if (Interlocked.CompareExchange(ref _exitedRaised, 1, 0) != 0) { return; } uint code = 0; try { if (_hProcess != IntPtr.Zero) { NativeMethods.GetExitCodeProcess(_hProcess, out code); } } catch { } _running = false; // Primary, safe path: flags polled by a UI-thread Timer. _lastExitCode = (int)code; _hasExited = true; // Legacy direct-callback path, best-effort only (see ReaderLoop comment above). try { Action<int> handler = ProcessExited; if (handler != null) { handler((int)code); } } catch { } } /// <summary>Fire-and-forget async write — never blocks the calling (UI) thread.</summary> public void Write(byte[] data) { if (!_running || data == null || data.Length == 0) { return; } IntPtr h = _hInputWrite; System.Threading.ThreadPool.QueueUserWorkItem(delegate { try { uint written; NativeMethods.WriteFile(h, data, (uint)data.Length, out written, IntPtr.Zero); } catch { /* pipe likely closing; ignore */ } }); } public void Write(string text) { if (string.IsNullOrEmpty(text)) { return; } Write(Encoding.UTF8.GetBytes(text)); } public void Resize(int cols, int rows) { if (_hPC == IntPtr.Zero) { return; } COORD size = new COORD((short)Math.Max(1, cols), (short)Math.Max(1, rows)); NativeMethods.ResizePseudoConsole(_hPC, size); } /// <summary>Graceful stop: close the pty (EOF to child), briefly wait, then hard-kill if needed.</summary> public void Stop(int gracefulWaitMs) { _running = false; try { if (_hPC != IntPtr.Zero) { NativeMethods.ClosePseudoConsole(_hPC); // signals EOF to the child's console I/O _hPC = IntPtr.Zero; } if (_hProcess != IntPtr.Zero && gracefulWaitMs > 0) { NativeMethods.WaitForSingleObject(_hProcess, (uint)gracefulWaitMs); } if (_hProcess != IntPtr.Zero) { uint code; bool stillRunning = NativeMethods.GetExitCodeProcess(_hProcess, out code) && code == 259; // STILL_ACTIVE if (stillRunning) { NativeMethods.TerminateProcess(_hProcess, 0); } } } catch { } finally { Cleanup(); } } private void Cleanup() { try { if (_hInputWrite != IntPtr.Zero) { NativeMethods.CloseHandle(_hInputWrite); _hInputWrite = IntPtr.Zero; } } catch { } try { if (_hOutputRead != IntPtr.Zero) { NativeMethods.CloseHandle(_hOutputRead); _hOutputRead = IntPtr.Zero; } } catch { } try { if (_hThread != IntPtr.Zero) { NativeMethods.CloseHandle(_hThread); _hThread = IntPtr.Zero; } } catch { } try { if (_hProcess != IntPtr.Zero) { NativeMethods.CloseHandle(_hProcess); _hProcess = IntPtr.Zero; } } catch { } try { if (_hPC != IntPtr.Zero) { NativeMethods.ClosePseudoConsole(_hPC); _hPC = IntPtr.Zero; } } catch { } try { if (_attrListPtr != IntPtr.Zero) { NativeMethods.DeleteProcThreadAttributeList(_attrListPtr); Marshal.FreeHGlobal(_attrListPtr); _attrListPtr = IntPtr.Zero; } } catch { } } private static void ThrowLastError(string api) { int err = Marshal.GetLastWin32Error(); throw new System.ComponentModel.Win32Exception(err, string.Format("{0} failed (Win32Error={1})", api, err)); } public void Dispose() { Stop(0); } } } '@ #endregion function Import-SRxConPtyNative { <# .SYNOPSIS Compiles/loads the SRx.Terminal.ConPtySession native-interop type (idempotent). #> [CmdletBinding()] param() if ([type]::GetType('SRx.Terminal.ConPtySession') -ne $null) { return $true } try { Add-Type -TypeDefinition $script:SRxConPtyCSharp -Language CSharp -ReferencedAssemblies @( 'System.dll','System.Core.dll' ) -ErrorAction Stop return $true } catch { Write-Host "Import-SRxConPtyNative - $($_.Exception.Message)" return $false } } function Test-SRxConPtySupported { <# .SYNOPSIS True if this OS build exposes CreatePseudoConsole (Win10 1809+ / Server 2019+). #> [CmdletBinding()] param() if (-not (Import-SRxConPtyNative)) { return $false } try { return [SRx.Terminal.ConPtySession]::IsSupported() } catch { return $false } } function New-SRxConPtySession { <# .SYNOPSIS Creates (but does not Start) a new ConPTY session object. #> [CmdletBinding()] [OutputType([object])] param() if (-not (Import-SRxConPtyNative)) { return $null } return [SRx.Terminal.ConPtySession]::new() } function Start-SRxConPtySession { <# .SYNOPSIS Starts (or restarts) a ConPTY session running the given command line. .PARAMETER Session An object from New-SRxConPtySession. If $null, a new one is created and returned. #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory = $false)] $Session = $null, [Parameter(Mandatory = $false)] [string]$CommandLine = 'powershell.exe -NoLogo -NoExit', [Parameter(Mandatory = $false)] [string]$WorkingDirectory = $null, [Parameter(Mandatory = $false)] [int]$Columns = 80, [Parameter(Mandatory = $false)] [int]$Rows = 24 ) if (-not $Session) { $Session = New-SRxConPtySession } if (-not $Session) { return $null } $Session.Start($CommandLine, $WorkingDirectory, $Columns, $Rows) return $Session } Export-ModuleMember -Function Import-SRxConPtyNative, Test-SRxConPtySupported, New-SRxConPtySession, Start-SRxConPtySession |