Skip to content

Fix macOS/Linux blocking: replace Console.ReadLine with polling PSCon… #92

Fix macOS/Linux blocking: replace Console.ReadLine with polling PSCon…

Fix macOS/Linux blocking: replace Console.ReadLine with polling PSCon… #92

name: Cross-Platform Test
on:
workflow_dispatch: # Manual trigger
push:
branches: [ main ]
paths:
- 'PowerShell.MCP/**'
- 'PowerShell.MCP.Proxy/**'
- 'Staging/**'
- 'Tests/**'
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
rid: win-x64
exe: PowerShell.MCP.Proxy.exe
- os: ubuntu-latest
rid: linux-x64
exe: PowerShell.MCP.Proxy
- os: macos-14
rid: osx-arm64
exe: PowerShell.MCP.Proxy
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
- name: Install PowerShell 7.5 (Windows)
if: runner.os == 'Windows'
run: |
# GitHub Actions has PowerShell 7.4 which uses .NET 8
# Install PowerShell 7.5+ (.NET 9) to test both runtimes via net9.0 path
# Use ZIP package to avoid MSI installation issues
$ErrorActionPreference = "Stop"
$url = "https://github.com/PowerShell/PowerShell/releases/download/v7.5.4/PowerShell-7.5.4-win-x64.zip"
$zipPath = "$env:TEMP\PowerShell-7.5.4-win-x64.zip"
$installPath = "C:\pwsh75"
Write-Host "Downloading PowerShell 7.5.4 ZIP..."
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
Write-Host "Downloaded: $((Get-Item $zipPath).Length) bytes"
Write-Host "Extracting to $installPath..."
Expand-Archive -Path $zipPath -DestinationPath $installPath -Force
Write-Host "Verifying installation..."
& "$installPath\pwsh.exe" --version
# Add to PATH for subsequent steps
echo "$installPath" | Out-File -FilePath $env:GITHUB_PATH -Append
shell: pwsh
- name: Install PowerShell (macOS)
if: runner.os == 'macOS'
run: |
brew install powershell/tap/powershell
- name: Install PowerShell (Linux)
if: runner.os == 'Linux'
run: |
# Install PowerShell on Ubuntu
sudo apt-get update
sudo apt-get install -y wget apt-transport-https software-properties-common
source /etc/os-release
wget -q https://packages.microsoft.com/config/ubuntu/$VERSION_ID/packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
sudo apt-get update
sudo apt-get install -y powershell
- name: Verify PowerShell
shell: pwsh
run: |
Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)"
Write-Host "PowerShell Path: $((Get-Process -Id $PID).Path)"
Write-Host "OS: $($PSVersionTable.OS)"
# On Windows, verify we're using 7.4+
if ($IsWindows -and $PSVersionTable.PSVersion.Major -eq 7 -and $PSVersionTable.PSVersion.Minor -lt 4) {
throw "Expected PowerShell 7.4+, got $($PSVersionTable.PSVersion)"
}
- name: Build PowerShell.MCP module
run: |
dotnet build PowerShell.MCP -c Release --no-incremental
- name: Build Proxy
run: |
dotnet publish PowerShell.MCP.Proxy -c Release -r ${{ matrix.rid }} --self-contained
- name: Run unit tests
run: |
dotnet test Tests/PowerShell.MCP.Tests.csproj -c Release -v normal
- name: Setup module directory (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$modulePath = "$env:USERPROFILE\Documents\PowerShell\Modules\PowerShell.MCP"
New-Item -Path "$modulePath\bin\${{ matrix.rid }}" -ItemType Directory -Force | Out-Null
Copy-Item "PowerShell.MCP\bin\Release\net8.0\PowerShell.MCP.dll" -Destination $modulePath
Copy-Item "PowerShell.MCP\bin\Release\net8.0\Ude.NetStandard.dll" -Destination $modulePath
Copy-Item "Staging\PowerShell.MCP.psd1" -Destination $modulePath
Copy-Item "Staging\PowerShell.MCP.psm1" -Destination $modulePath
Copy-Item "PowerShell.MCP.Proxy\bin\Release\net9.0\${{ matrix.rid }}\publish\${{ matrix.exe }}" -Destination "$modulePath\bin\${{ matrix.rid }}"
Write-Host "Module files:"
Get-ChildItem $modulePath -Recurse | Select-Object FullName
- name: Setup module directory (Linux/macOS)
if: runner.os != 'Windows'
run: |
MODULE_PATH="$HOME/.local/share/powershell/Modules/PowerShell.MCP"
mkdir -p "$MODULE_PATH/bin/${{ matrix.rid }}"
cp PowerShell.MCP/bin/Release/net8.0/PowerShell.MCP.dll "$MODULE_PATH/"
cp PowerShell.MCP/bin/Release/net8.0/Ude.NetStandard.dll "$MODULE_PATH/"
cp Staging/PowerShell.MCP.psd1 "$MODULE_PATH/"
cp Staging/PowerShell.MCP.psm1 "$MODULE_PATH/"
cp "PowerShell.MCP.Proxy/bin/Release/net9.0/${{ matrix.rid }}/publish/${{ matrix.exe }}" "$MODULE_PATH/bin/${{ matrix.rid }}/"
chmod +x "$MODULE_PATH/bin/${{ matrix.rid }}/${{ matrix.exe }}"
echo "Module files:"
ls -laR "$MODULE_PATH/"
- name: Test module import
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
Write-Host "=== Importing module ===" -ForegroundColor Cyan
Import-Module PowerShell.MCP -Verbose
Write-Host "`n=== Module info ===" -ForegroundColor Cyan
Get-Module PowerShell.MCP | Format-List Name, Version, ModuleBase
Write-Host "`n=== Get-MCPProxyPath ===" -ForegroundColor Cyan
$proxyPath = Get-MCPProxyPath
Write-Host "Proxy path: $proxyPath"
if (-not (Test-Path $proxyPath)) { throw "Proxy not found at $proxyPath" }
Write-Host "`n=== Get-MCPProxyPath -Escape ===" -ForegroundColor Cyan
$escapedPath = Get-MCPProxyPath -Escape
Write-Host "Escaped path: $escapedPath"
Write-Host "`n=== PSReadLine status ===" -ForegroundColor Cyan
$psrl = Get-Module PSReadLine
if ($IsWindows) {
if ($psrl) {
Write-Host "PSReadLine is loaded (expected on Windows)" -ForegroundColor Green
} else {
Write-Host "PSReadLine is NOT loaded (unexpected on Windows)" -ForegroundColor Yellow
}
} else {
if ($psrl) {
Write-Host "PSReadLine is loaded (unexpected on Linux/macOS)" -ForegroundColor Yellow
} else {
Write-Host "PSReadLine is NOT loaded (expected on Linux/macOS)" -ForegroundColor Green
}
}
Write-Host "`n=== PSConsoleHostReadLine fix (non-Windows) ===" -ForegroundColor Cyan
if (-not $IsWindows) {
# PSReadLine defines PSConsoleHostReadLine when loaded.
# After Remove-Module PSReadLine, our psm1 defines a custom polling version.
$fn = Get-Command PSConsoleHostReadLine -ErrorAction SilentlyContinue
if (-not $fn) {
throw "PSConsoleHostReadLine not defined - fix not applied"
}
Write-Host "PSConsoleHostReadLine: DEFINED (custom polling implementation)" -ForegroundColor Green
Write-Host " CommandType : $($fn.CommandType)"
Write-Host " Definition : $($fn.Definition.Substring(0, [Math]::Min(80, $fn.Definition.Length)))..."
}
Write-Host "`n=== All tests passed ===" -ForegroundColor Green
- name: Test timer event polling mechanism (non-Windows)
if: runner.os != 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
Import-Module PowerShell.MCP
# Verify that Register-ObjectEvent action blocks fire during Start-Sleep polling.
# This is the core mechanism of the PSConsoleHostReadLine fix:
# the custom ReadLine polls Console.KeyAvailable + Start-Sleep 50ms in a loop,
# and Start-Sleep must yield the runspace long enough for the timer action to run.
Write-Host "=== Timer event fires during Start-Sleep polling ===" -ForegroundColor Cyan
$global:timerFired = $false
$timer = New-Object System.Timers.Timer 150
$timer.AutoReset = $false
Register-ObjectEvent -InputObject $timer -EventName Elapsed -Action {
$global:timerFired = $true
} | Out-Null
$timer.Start()
# Simulate PSConsoleHostReadLine's inner loop: poll every 50ms for up to 2s
$deadline = [DateTime]::UtcNow.AddMilliseconds(2000)
while (-not $global:timerFired -and [DateTime]::UtcNow -lt $deadline) {
Start-Sleep -Milliseconds 50
}
if (-not $global:timerFired) {
throw "FAIL: Timer event did NOT fire during Start-Sleep polling. The PSConsoleHostReadLine fix will not work on this platform."
}
Write-Host "PASS: Timer event fired during Start-Sleep polling." -ForegroundColor Green
Write-Host "The PSConsoleHostReadLine fix is effective on this platform." -ForegroundColor Green
- name: Test Proxy JSON-RPC communication
shell: pwsh
timeout-minutes: 2
run: |
$ErrorActionPreference = "Stop"
Write-Host "=== Testing Proxy JSON-RPC ===" -ForegroundColor Cyan
$proxyPath = Get-MCPProxyPath
Write-Host "Proxy path: $proxyPath"
# Start Proxy process with redirected stdin/stdout
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $proxyPath
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$process = [System.Diagnostics.Process]::Start($psi)
Write-Host "Proxy started with PID: $($process.Id)"
# Use async reading with timeout
function Send-JsonRpc {
param([string]$Json, [int]$TimeoutMs = 5000)
Write-Host "Sending: $Json"
$process.StandardInput.WriteLine($Json)
$process.StandardInput.Flush()
# Read response line with timeout
$task = $process.StandardOutput.ReadLineAsync()
if ($task.Wait($TimeoutMs)) {
return $task.Result
} else {
throw "Timeout waiting for response"
}
}
try {
# Test 1: Initialize
Write-Host "`n=== Test 1: Initialize ===" -ForegroundColor Yellow
$initRequest = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
$response = Send-JsonRpc $initRequest
Write-Host "Response: $response"
if ($response -match '"protocolVersion"') {
Write-Host "Initialize: PASSED" -ForegroundColor Green
} else {
throw "Initialize failed - no protocolVersion in response"
}
# Send initialized notification (no response expected)
Write-Host "`nSending initialized notification..."
$process.StandardInput.WriteLine('{"jsonrpc":"2.0","method":"notifications/initialized"}')
$process.StandardInput.Flush()
Start-Sleep -Milliseconds 200
# Test 2: List tools
Write-Host "`n=== Test 2: List Tools ===" -ForegroundColor Yellow
$listRequest = '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
$response = Send-JsonRpc $listRequest
Write-Host "Response: $response"
if ($response -match '"get_current_location"' -and $response -match '"invoke_expression"') {
Write-Host "List Tools: PASSED" -ForegroundColor Green
} else {
throw "List Tools failed - expected tools not found"
}
Write-Host "`n=== All JSON-RPC tests passed ===" -ForegroundColor Green
} finally {
if (-not $process.HasExited) { $process.Kill() }
$process.Dispose()
}
- name: Test Named Pipe communication
if: runner.os != 'Windows' # Skip on Windows due to CI environment issues
shell: pwsh
timeout-minutes: 2
run: |
$ErrorActionPreference = "Stop"
Write-Host "=== Testing Named Pipe Communication ===" -ForegroundColor Cyan
# Clean up any existing Named Pipe socket files (Linux/macOS)
$pipeName = "PowerShell.MCP.Communication"
if (-not $IsWindows) {
# Check both /tmp and $TMPDIR (macOS uses $TMPDIR)
$pipeLocations = @("/tmp/CoreFxPipe_$pipeName")
if ($env:TMPDIR) {
$pipeLocations += "$env:TMPDIR/CoreFxPipe_$pipeName"
}
foreach ($pipeFile in $pipeLocations) {
if (Test-Path $pipeFile) {
Write-Host "Removing existing Named Pipe socket: $pipeFile"
Remove-Item $pipeFile -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
}
}
}
# Start a background pwsh process with PowerShell.MCP imported
Write-Host "Starting background pwsh with PowerShell.MCP..."
$bgPsi = [System.Diagnostics.ProcessStartInfo]::new()
$bgPsi.FileName = "pwsh"
$bgPsi.Arguments = "-NoProfile -NoExit -Command `"Import-Module PowerShell.MCP -Verbose`""
$bgPsi.UseShellExecute = $false
$bgPsi.CreateNoWindow = $true
$bgPsi.RedirectStandardInput = $true
$bgPsi.RedirectStandardOutput = $true
$bgPsi.RedirectStandardError = $true
$bgProcess = [System.Diagnostics.Process]::Start($bgPsi)
Write-Host "Background pwsh PID: $($bgProcess.Id)"
# Wait for Named Pipe server to be ready
Write-Host "Waiting for Named Pipe server..."
$pipeName = "PowerShell.MCP.Communication.$($bgProcess.Id)"
$pipeReady = $false
for ($i = 0; $i -lt 30; $i++) {
Start-Sleep -Milliseconds 500
# Check if process is still running
if ($bgProcess.HasExited) {
Write-Host "ERROR: Background pwsh exited with code $($bgProcess.ExitCode)" -ForegroundColor Red
Write-Host "=== stdout ===" -ForegroundColor Yellow
Write-Host $bgProcess.StandardOutput.ReadToEnd()
Write-Host "=== stderr ===" -ForegroundColor Yellow
Write-Host $bgProcess.StandardError.ReadToEnd()
throw "Background pwsh process exited unexpectedly"
}
if ($IsWindows) {
$pipeReady = Test-Path "\\.\pipe\$pipeName"
} else {
# Check both /tmp and $TMPDIR (macOS uses $TMPDIR)
$pipeReady = (Test-Path "/tmp/CoreFxPipe_$pipeName") -or
($env:TMPDIR -and (Test-Path "$env:TMPDIR/CoreFxPipe_$pipeName"))
}
if ($pipeReady) {
Write-Host "Named Pipe ready after $($i * 500)ms"
break
}
# Show progress every 5 seconds
if ($i % 10 -eq 9) {
Write-Host "Still waiting... ($($i * 500)ms elapsed)"
}
}
if (-not $pipeReady) {
Write-Host "=== Named Pipe not found, checking process state ===" -ForegroundColor Red
Write-Host "Process running: $(-not $bgProcess.HasExited)"
if (-not $bgProcess.HasExited) {
Write-Host "=== Available stdout ===" -ForegroundColor Yellow
while ($bgProcess.StandardOutput.Peek() -ge 0) {
Write-Host ([char]$bgProcess.StandardOutput.Read()) -NoNewline
}
Write-Host ""
Write-Host "=== Available stderr ===" -ForegroundColor Yellow
while ($bgProcess.StandardError.Peek() -ge 0) {
Write-Host ([char]$bgProcess.StandardError.Read()) -NoNewline
}
Write-Host ""
$bgProcess.Kill()
}
throw "Named Pipe server did not start within 15 seconds"
}
# Pump PowerShell events via stdin (required for macOS/Linux where timer events
# don't fire automatically when stdin is redirected)
Write-Host "Sending event pump loop via stdin..."
$bgProcess.StandardInput.WriteLine("while (`$true) { Start-Sleep -Milliseconds 200 }")
$bgProcess.StandardInput.Flush()
Start-Sleep -Seconds 2
# Start Proxy (build path directly to avoid importing PowerShell.MCP in test script)
Write-Host "Starting Proxy..."
if ($IsWindows) {
$proxyPath = "$env:USERPROFILE\Documents\PowerShell\Modules\PowerShell.MCP\bin\win-x64\PowerShell.MCP.Proxy.exe"
} elseif ($IsMacOS) {
$proxyPath = "$HOME/.local/share/powershell/Modules/PowerShell.MCP/bin/osx-arm64/PowerShell.MCP.Proxy"
} else {
$proxyPath = "$HOME/.local/share/powershell/Modules/PowerShell.MCP/bin/linux-x64/PowerShell.MCP.Proxy"
}
Write-Host "Proxy path: $proxyPath"
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $proxyPath
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$process = [System.Diagnostics.Process]::Start($psi)
# Use async reading with timeout
function Send-JsonRpc {
param([string]$Json, [int]$TimeoutMs = 5000)
Write-Host "Sending: $Json"
$process.StandardInput.WriteLine($Json)
$process.StandardInput.Flush()
$task = $process.StandardOutput.ReadLineAsync()
if ($task.Wait($TimeoutMs)) {
return $task.Result
} else {
throw "Timeout waiting for response"
}
}
try {
# Initialize
$initRequest = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
$response = Send-JsonRpc $initRequest
Write-Host "Init response: $response"
$process.StandardInput.WriteLine('{"jsonrpc":"2.0","method":"notifications/initialized"}')
$process.StandardInput.Flush()
Start-Sleep -Milliseconds 200
# invoke_expression: PSConsoleHostReadLine fix enables timer event processing
# via Start-Sleep polling. This is verified by the preceding
# "Test timer event polling mechanism" step. Both CI (via the stdin event-pump
# loop already sent above) and interactive (via PSConsoleHostReadLine) use the
# same Start-Sleep yield mechanism.
# Test: invoke_expression with Get-Date (first call - should return get_current_location)
Write-Host "`n=== Test: invoke_expression (Get-Date) - First call ===" -ForegroundColor Yellow
$invokeRequest = '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Get-Date -Format yyyy-MM-dd"}}}'
$response = Send-JsonRpc $invokeRequest 60000
Write-Host "Response (truncated): $($response.Substring(0, [Math]::Min(200, $response.Length)))..."
if ($response -match "Switched to console" -and $response -match "Pipeline NOT executed") {
Write-Host "First invoke_expression: PASSED (expected behavior - command not executed)" -ForegroundColor Green
} else {
throw "First invoke_expression did not return expected ''Switched to console'' message"
}
# Test: invoke_expression with Get-Date (second call - should execute)
Write-Host "`n=== Test: invoke_expression (Get-Date) - Second call ===" -ForegroundColor Yellow
$invokeRequest = '{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Get-Date -Format yyyy-MM-dd"}}}'
$response = Send-JsonRpc $invokeRequest 60000
Write-Host "Response: $response"
$today = Get-Date -Format "yyyy-MM-dd"
if ($response -match $today) {
Write-Host "Second invoke_expression (Get-Date): PASSED" -ForegroundColor Green
} else {
throw "Second invoke_expression failed - expected date $today not found"
}
Write-Host "`n=== All Named Pipe tests passed ===" -ForegroundColor Green
} finally {
if (-not $process.HasExited) { $process.Kill() }
$process.Dispose()
if (-not $bgProcess.HasExited) { $bgProcess.Kill() }
$bgProcess.Dispose()
}