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