Skip to content

Commit cc0b441

Browse files
yotsudaclaude
andcommitted
Fix macOS/Linux blocking: replace Console.ReadLine with polling PSConsoleHostReadLine
On macOS/Linux without PSReadLine, the built-in PSConsoleHostReadLine calls Console.ReadLine() which blocks the main runspace, preventing the MCP timer action block from firing until the user presses Enter in Terminal.app. Fix: define a custom global:PSConsoleHostReadLine in the psm1 that polls Console.KeyAvailable + Start-Sleep 50ms in a loop. PowerShell processes the event queue between pipeline statements, so the MCP timer action block (Register-ObjectEvent) fires during each Sleep without requiring user input. CI: add two new test steps to cross-platform-test.yml: - PSConsoleHostReadLine is defined on non-Windows after module import - Timer event fires during Start-Sleep polling (core mechanism validation) Also remove the macOS invoke_expression skip from Named Pipe test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 322c9d6 commit cc0b441

File tree

2 files changed

+115
-34
lines changed

2 files changed

+115
-34
lines changed

.github/workflows/cross-platform-test.yml

Lines changed: 74 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,54 @@ jobs:
177177
}
178178
}
179179
180+
Write-Host "`n=== PSConsoleHostReadLine fix (non-Windows) ===" -ForegroundColor Cyan
181+
if (-not $IsWindows) {
182+
# PSReadLine defines PSConsoleHostReadLine when loaded.
183+
# After Remove-Module PSReadLine, our psm1 defines a custom polling version.
184+
$fn = Get-Command PSConsoleHostReadLine -ErrorAction SilentlyContinue
185+
if (-not $fn) {
186+
throw "PSConsoleHostReadLine not defined - fix not applied"
187+
}
188+
Write-Host "PSConsoleHostReadLine: DEFINED (custom polling implementation)" -ForegroundColor Green
189+
Write-Host " CommandType : $($fn.CommandType)"
190+
Write-Host " Definition : $($fn.Definition.Substring(0, [Math]::Min(80, $fn.Definition.Length)))..."
191+
}
192+
180193
Write-Host "`n=== All tests passed ===" -ForegroundColor Green
181194
195+
- name: Test timer event polling mechanism (non-Windows)
196+
if: runner.os != 'Windows'
197+
shell: pwsh
198+
run: |
199+
$ErrorActionPreference = "Stop"
200+
Import-Module PowerShell.MCP
201+
202+
# Verify that Register-ObjectEvent action blocks fire during Start-Sleep polling.
203+
# This is the core mechanism of the PSConsoleHostReadLine fix:
204+
# the custom ReadLine polls Console.KeyAvailable + Start-Sleep 50ms in a loop,
205+
# and Start-Sleep must yield the runspace long enough for the timer action to run.
206+
Write-Host "=== Timer event fires during Start-Sleep polling ===" -ForegroundColor Cyan
207+
208+
$global:timerFired = $false
209+
$timer = New-Object System.Timers.Timer 150
210+
$timer.AutoReset = $false
211+
Register-ObjectEvent -InputObject $timer -EventName Elapsed -Action {
212+
$global:timerFired = $true
213+
} | Out-Null
214+
$timer.Start()
215+
216+
# Simulate PSConsoleHostReadLine's inner loop: poll every 50ms for up to 2s
217+
$deadline = [DateTime]::UtcNow.AddMilliseconds(2000)
218+
while (-not $global:timerFired -and [DateTime]::UtcNow -lt $deadline) {
219+
Start-Sleep -Milliseconds 50
220+
}
221+
222+
if (-not $global:timerFired) {
223+
throw "FAIL: Timer event did NOT fire during Start-Sleep polling. The PSConsoleHostReadLine fix will not work on this platform."
224+
}
225+
Write-Host "PASS: Timer event fired during Start-Sleep polling." -ForegroundColor Green
226+
Write-Host "The PSConsoleHostReadLine fix is effective on this platform." -ForegroundColor Green
227+
182228
- name: Test Proxy JSON-RPC communication
183229
shell: pwsh
184230
timeout-minutes: 2
@@ -402,43 +448,39 @@ jobs:
402448
$process.StandardInput.Flush()
403449
Start-Sleep -Milliseconds 200
404450
405-
# invoke_expression test requires timer event processing (Register-ObjectEvent -Action).
406-
# On macOS, timer events don't fire in non-interactive mode (redirected stdin),
407-
# even with Start-Sleep or Wait-Event pumping. This is a CI-specific limitation;
408-
# production macOS usage launches Terminal.app (interactive) where events work normally.
409-
if ($IsMacOS) {
410-
Write-Host "`n=== Skipping invoke_expression test on macOS (timer events require interactive session) ===" -ForegroundColor Yellow
411-
Write-Host "Named Pipe server detection and Proxy JSON-RPC communication verified successfully."
412-
Write-Host "`n=== Named Pipe tests passed (macOS: basic connectivity) ===" -ForegroundColor Green
451+
# invoke_expression: PSConsoleHostReadLine fix enables timer event processing
452+
# via Start-Sleep polling. This is verified by the preceding
453+
# "Test timer event polling mechanism" step. Both CI (via the stdin event-pump
454+
# loop already sent above) and interactive (via PSConsoleHostReadLine) use the
455+
# same Start-Sleep yield mechanism.
456+
457+
# Test: invoke_expression with Get-Date (first call - should return get_current_location)
458+
Write-Host "`n=== Test: invoke_expression (Get-Date) - First call ===" -ForegroundColor Yellow
459+
$invokeRequest = '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Get-Date -Format yyyy-MM-dd"}}}'
460+
$response = Send-JsonRpc $invokeRequest 60000
461+
Write-Host "Response (truncated): $($response.Substring(0, [Math]::Min(200, $response.Length)))..."
462+
463+
if ($response -match "Switched to console" -and $response -match "Pipeline NOT executed") {
464+
Write-Host "First invoke_expression: PASSED (expected behavior - command not executed)" -ForegroundColor Green
413465
} else {
414-
# Test: invoke_expression with Get-Date (first call - should return get_current_location)
415-
Write-Host "`n=== Test: invoke_expression (Get-Date) - First call ===" -ForegroundColor Yellow
416-
$invokeRequest = '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Get-Date -Format yyyy-MM-dd"}}}'
417-
$response = Send-JsonRpc $invokeRequest 60000
418-
Write-Host "Response (truncated): $($response.Substring(0, [Math]::Min(200, $response.Length)))..."
419-
420-
if ($response -match "Switched to console" -and $response -match "Pipeline NOT executed") {
421-
Write-Host "First invoke_expression: PASSED (expected behavior - command not executed)" -ForegroundColor Green
422-
} else {
423-
throw "First invoke_expression did not return expected ''Switched to console'' message"
424-
}
466+
throw "First invoke_expression did not return expected ''Switched to console'' message"
467+
}
425468
426-
# Test: invoke_expression with Get-Date (second call - should execute)
427-
Write-Host "`n=== Test: invoke_expression (Get-Date) - Second call ===" -ForegroundColor Yellow
428-
$invokeRequest = '{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Get-Date -Format yyyy-MM-dd"}}}'
429-
$response = Send-JsonRpc $invokeRequest 60000
430-
Write-Host "Response: $response"
431-
432-
$today = Get-Date -Format "yyyy-MM-dd"
433-
if ($response -match $today) {
434-
Write-Host "Second invoke_expression (Get-Date): PASSED" -ForegroundColor Green
435-
} else {
436-
throw "Second invoke_expression failed - expected date $today not found"
437-
}
469+
# Test: invoke_expression with Get-Date (second call - should execute)
470+
Write-Host "`n=== Test: invoke_expression (Get-Date) - Second call ===" -ForegroundColor Yellow
471+
$invokeRequest = '{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"invoke_expression","arguments":{"pipeline":"Get-Date -Format yyyy-MM-dd"}}}'
472+
$response = Send-JsonRpc $invokeRequest 60000
473+
Write-Host "Response: $response"
438474
439-
Write-Host "`n=== All Named Pipe tests passed ===" -ForegroundColor Green
475+
$today = Get-Date -Format "yyyy-MM-dd"
476+
if ($response -match $today) {
477+
Write-Host "Second invoke_expression (Get-Date): PASSED" -ForegroundColor Green
478+
} else {
479+
throw "Second invoke_expression failed - expected date $today not found"
440480
}
441481
482+
Write-Host "`n=== All Named Pipe tests passed ===" -ForegroundColor Green
483+
442484
} finally {
443485
if (-not $process.HasExited) { $process.Kill() }
444486
$process.Dispose()

Staging/PowerShell.MCP.psm1

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,49 @@
22
# Provides automatic cleanup when Remove-Module is executed
33

44

5-
# On Linux/macOS, PSReadLine interferes with timer events
6-
# Remove it to ensure MCP polling works correctly
5+
# On Linux/macOS, PSReadLine interferes with timer events.
6+
# Remove it and replace with a custom PSConsoleHostReadLine that polls
7+
# Console.KeyAvailable instead of blocking on Console.ReadLine().
8+
# This allows the MCP timer event action block to run between input polls.
79
if (-not $IsWindows) {
810
Remove-Module PSReadLine -ErrorAction SilentlyContinue
11+
12+
function global:PSConsoleHostReadLine {
13+
$line = [System.Text.StringBuilder]::new()
14+
while ($true) {
15+
if ([Console]::KeyAvailable) {
16+
$key = [Console]::ReadKey($true)
17+
switch ($key.Key) {
18+
'Enter' {
19+
[Console]::WriteLine()
20+
return $line.ToString()
21+
}
22+
'Backspace' {
23+
if ($line.Length -gt 0) {
24+
$line.Length--
25+
[Console]::Write("`b `b")
26+
}
27+
}
28+
default {
29+
# Ctrl+C: cancel current line
30+
if ($key.Key -eq 'C' -and ($key.Modifiers -band [ConsoleModifiers]::Control)) {
31+
[Console]::WriteLine("^C")
32+
return ""
33+
}
34+
if ($key.KeyChar -ge ' ') {
35+
$line.Append($key.KeyChar) | Out-Null
36+
[Console]::Write($key.KeyChar)
37+
}
38+
}
39+
}
40+
} else {
41+
# No input available - sleep briefly.
42+
# PowerShell processes the event queue between pipeline statements,
43+
# so the MCP timer action block can run during this Sleep.
44+
Start-Sleep -Milliseconds 50
45+
}
46+
}
47+
}
948
}
1049

1150
# Set OnRemove script block to execute cleanup automatically

0 commit comments

Comments
 (0)