Skip to content

Native executables hang indefinitely when reading stdin on Windows (e.g. go vet, go build) #36

@doraemonkeys

Description

@doraemonkeys

Description

On Windows, certain native executables (notably Go toolchain commands like go vet, go build, go test) hang indefinitely when executed via invoke_expression. The command blocks until the user manually presses Enter in the PowerShell console window.

Environment

  • OS: Windows 10/11
  • PowerShell.MCP version: 1.7.0
  • PowerShell: 7.5.4
  • MCP Client: Claude Code

Steps to Reproduce

  1. Use invoke_expression to run a Go toolchain command:
    go vet ./pkg/...
    
  2. The command hangs indefinitely. The console shows Status: Busy and never completes.
  3. Manually pressing Enter in the PowerShell console window unblocks it immediately and the command completes.

Workaround

Piping empty stdin via Get-Content NUL | resolves the issue:

Get-Content NUL | go vet ./pkg/... 2>&1

This completes in under 1 second, confirming the root cause is stdin-related.

Root Cause Analysis

The issue is in MCPPollingEngine.ps1, specifically in Invoke-CommandWithAllStreams (line 71–85). Commands are executed via Invoke-Expression, which causes child processes (native executables) to inherit the console's stdin handle.

The execution chain:

  1. PwshLauncherWindows.LaunchPwsh() creates the PowerShell console with CREATE_NEW_CONSOLE flag (PowerShellProcessManager.cs:265), giving it a real console input buffer.
  2. Import-Module PSReadLine is loaded in the console (PowerShellProcessManager.cs:262), which puts the console in raw input mode.
  3. When the polling engine timer fires (MCPPollingEngine.ps1), it calls Invoke-CommandWithAllStreamsInvoke-Expression $Command.
  4. Native executables spawned by Invoke-Expression inherit the console's stdin handle.
  5. Some native runtimes (e.g. Go) detect that stdin is a console/terminal and attempt to read from it (or check its state). Since the console input buffer is empty (no user is typing), the read blocks indefinitely.
  6. When the user presses Enter, data enters the console input buffer, satisfying the pending read, and the command unblocks.

The Get-Content NUL | workaround works because PowerShell replaces the native command's stdin with a pipe (which immediately returns EOF) instead of the console handle. The Go runtime detects piped input rather than a terminal and does not attempt to read.

Proposed Fix

Temporarily redirect the process-level stdin handle to NUL before executing MCP commands, and restore it afterwards. This can be done via Win32 SetStdHandle(STD_INPUT_HANDLE, ...) in MCPPollingEngine.ps1:

# Add P/Invoke for stdin redirection (one-time, Windows only)
if ($IsWindows -and -not ('MCP.StdinRedirector' -as [type])) {
    Add-Type -Name 'StdinRedirector' -Namespace 'MCP' -MemberDefinition @'
        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern IntPtr GetStdHandle(int nStdHandle);

        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern bool SetStdHandle(int nStdHandle, IntPtr hHandle);

        private const int STD_INPUT_HANDLE = -10;
        private static IntPtr _savedHandle;
        private static System.IO.FileStream _nulStream;

        public static void RedirectToNul() {
            _savedHandle = GetStdHandle(STD_INPUT_HANDLE);
            _nulStream = System.IO.File.OpenRead("\\\\.\\NUL");
            SetStdHandle(STD_INPUT_HANDLE, _nulStream.SafeFileHandle.DangerousGetHandle());
        }

        public static void Restore() {
            if (_savedHandle != IntPtr.Zero) {
                SetStdHandle(STD_INPUT_HANDLE, _savedHandle);
                _savedHandle = IntPtr.Zero;
            }
            if (_nulStream != null) {
                _nulStream.Dispose();
                _nulStream = null;
            }
        }
'@
}

Then in Invoke-CommandWithAllStreams:

function Invoke-CommandWithAllStreams {
    param([string]$Command)
    ...
    if ($IsWindows) { [MCP.StdinRedirector]::RedirectToNul() }
    try {
        $redirectedOutput = Invoke-Expression $Command ...
    }
    ...
    finally {
        if ($IsWindows) { [MCP.StdinRedirector]::Restore() }
    }
}

Why this is safe

  • The timer callback and PSReadLine both run on the main PowerShell thread — there is no concurrency race. PSReadLine is not reading keys while the timer handler is executing.
  • .NET's Console class caches the original console input handle on first use. SetStdHandle changes the process-level handle (inherited by child processes), but does not affect .NET's cached handle. So Console.ReadKey() (used by PSReadLine) continues to work correctly after restore.
  • This only affects Windows. On macOS/Linux, PSReadLine is explicitly removed (Remove-Module PSReadLine), so the issue does not occur.

Alternative (more comprehensive)

For the timeout_seconds=0 case (interactive commands where stdin is intentionally needed), a more precise fix would pass timeoutSeconds through CommandSlot to the polling engine, and only redirect stdin when timeoutSeconds > 0. This requires changes to both C# (McpServerHost.CommandSlot) and PowerShell (MCPPollingEngine.ps1).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions