Skip to content

Commit ec69893

Browse files
fix: implement non-blocking pipe reads on Windows to prevent agent freeze
1 parent 9441ec1 commit ec69893

2 files changed

Lines changed: 218 additions & 18 deletions

File tree

code_puppy/tools/command_runner.py

Lines changed: 111 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ctypes
12
import os
23
import select
34
import signal
@@ -38,6 +39,60 @@ def _truncate_line(line: str) -> str:
3839
return line
3940

4041

42+
# Windows-specific: Check if pipe has data available without blocking
43+
# This is needed because select() doesn't work on pipes on Windows
44+
if sys.platform.startswith("win"):
45+
import msvcrt
46+
47+
# Load kernel32 for PeekNamedPipe
48+
_kernel32 = ctypes.windll.kernel32
49+
50+
def _win32_pipe_has_data(pipe) -> bool:
51+
"""Check if a Windows pipe has data available without blocking.
52+
53+
Uses PeekNamedPipe from kernel32.dll to check if there's data
54+
in the pipe buffer without actually reading it.
55+
56+
Args:
57+
pipe: A file object with a fileno() method (e.g., process.stdout)
58+
59+
Returns:
60+
True if data is available, False otherwise (including on error)
61+
"""
62+
try:
63+
# Get the Windows handle from the file descriptor
64+
handle = msvcrt.get_osfhandle(pipe.fileno())
65+
66+
# PeekNamedPipe parameters:
67+
# - hNamedPipe: handle to the pipe
68+
# - lpBuffer: buffer to receive data (NULL = don't read)
69+
# - nBufferSize: size of buffer (0 = don't read)
70+
# - lpBytesRead: receives bytes read (NULL)
71+
# - lpTotalBytesAvail: receives total bytes available
72+
# - lpBytesLeftThisMessage: receives bytes left (NULL)
73+
bytes_available = ctypes.c_ulong(0)
74+
75+
result = _kernel32.PeekNamedPipe(
76+
handle,
77+
None, # Don't read data
78+
0, # Buffer size 0
79+
None, # Don't care about bytes read
80+
ctypes.byref(bytes_available), # Get bytes available
81+
None, # Don't care about bytes left in message
82+
)
83+
84+
if result:
85+
return bytes_available.value > 0
86+
return False
87+
except (ValueError, OSError, ctypes.ArgumentError):
88+
# Handle closed, invalid, or other errors
89+
return False
90+
else:
91+
# POSIX stub - not used, but keeps the code clean
92+
def _win32_pipe_has_data(pipe) -> bool:
93+
return False
94+
95+
4196
_AWAITING_USER_INPUT = False
4297

4398
_CONFIRMATION_LOCK = threading.Lock()
@@ -468,17 +523,35 @@ def read_stdout():
468523

469524
# Use select to check if data is available (with timeout)
470525
if sys.platform.startswith("win"):
471-
# Windows doesn't support select on pipes, use a different approach
472-
# Just try to read with a check on the stop event
526+
# Windows doesn't support select on pipes
527+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
528+
# if data is available without blocking
473529
try:
474-
line = process.stdout.readline()
475-
if not line: # EOF
476-
break
477-
line = line.rstrip("\n\r")
478-
line = _truncate_line(line)
479-
stdout_lines.append(line)
480-
emit_shell_line(line, stream="stdout")
481-
last_output_time[0] = time.time()
530+
if _win32_pipe_has_data(process.stdout):
531+
line = process.stdout.readline()
532+
if not line: # EOF
533+
break
534+
line = line.rstrip("\n\r")
535+
line = _truncate_line(line)
536+
stdout_lines.append(line)
537+
emit_shell_line(line, stream="stdout")
538+
last_output_time[0] = time.time()
539+
else:
540+
# No data available, check if process has exited
541+
if process.poll() is not None:
542+
# Process exited, do one final drain
543+
try:
544+
remaining = process.stdout.read()
545+
if remaining:
546+
for line in remaining.splitlines():
547+
line = _truncate_line(line)
548+
stdout_lines.append(line)
549+
emit_shell_line(line, stream="stdout")
550+
except (ValueError, OSError):
551+
pass
552+
break
553+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
554+
time.sleep(0.1)
482555
except (ValueError, OSError):
483556
break
484557
else:
@@ -516,15 +589,35 @@ def read_stderr():
516589
break
517590

518591
if sys.platform.startswith("win"):
592+
# Windows doesn't support select on pipes
593+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
594+
# if data is available without blocking
519595
try:
520-
line = process.stderr.readline()
521-
if not line: # EOF
522-
break
523-
line = line.rstrip("\n\r")
524-
line = _truncate_line(line)
525-
stderr_lines.append(line)
526-
emit_shell_line(line, stream="stderr")
527-
last_output_time[0] = time.time()
596+
if _win32_pipe_has_data(process.stderr):
597+
line = process.stderr.readline()
598+
if not line: # EOF
599+
break
600+
line = line.rstrip("\n\r")
601+
line = _truncate_line(line)
602+
stderr_lines.append(line)
603+
emit_shell_line(line, stream="stderr")
604+
last_output_time[0] = time.time()
605+
else:
606+
# No data available, check if process has exited
607+
if process.poll() is not None:
608+
# Process exited, do one final drain
609+
try:
610+
remaining = process.stderr.read()
611+
if remaining:
612+
for line in remaining.splitlines():
613+
line = _truncate_line(line)
614+
stderr_lines.append(line)
615+
emit_shell_line(line, stream="stderr")
616+
except (ValueError, OSError):
617+
pass
618+
break
619+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
620+
time.sleep(0.1)
528621
except (ValueError, OSError):
529622
break
530623
else:

tests/test_windows_pipe.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Test Windows pipe non-blocking read functionality."""
2+
import subprocess
3+
import sys
4+
import threading
5+
import time
6+
7+
import pytest
8+
9+
from code_puppy.tools.command_runner import _win32_pipe_has_data
10+
11+
12+
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only")
13+
class TestWin32PipeHasData:
14+
"""Tests for the _win32_pipe_has_data function."""
15+
16+
def test_returns_true_when_data_available(self):
17+
"""Test that function returns True when pipe has data."""
18+
proc = subprocess.Popen(
19+
"echo hello",
20+
shell=True,
21+
stdout=subprocess.PIPE,
22+
text=True,
23+
)
24+
proc.wait()
25+
time.sleep(0.1) # Give buffer time to fill
26+
27+
assert _win32_pipe_has_data(proc.stdout) is True
28+
proc.stdout.close()
29+
30+
def test_returns_false_when_no_data(self):
31+
"""Test that function returns False when pipe has no data yet."""
32+
# Use a command that takes time to produce output
33+
proc = subprocess.Popen(
34+
"ping -n 3 127.0.0.1 >nul && echo done",
35+
shell=True,
36+
stdout=subprocess.PIPE,
37+
text=True,
38+
)
39+
40+
# Check immediately - should have no data
41+
result = _win32_pipe_has_data(proc.stdout)
42+
proc.kill()
43+
proc.wait()
44+
45+
# Should be False (no output yet)
46+
assert result is False
47+
48+
def test_reader_loop_can_be_stopped(self):
49+
"""Test that a reader loop using _win32_pipe_has_data can be stopped via event."""
50+
# This is the key test - simulates the frozen agent scenario
51+
proc = subprocess.Popen(
52+
"ping -n 1000 127.0.0.1 >nul", # Long-running, no stdout output
53+
shell=True,
54+
stdout=subprocess.PIPE,
55+
stderr=subprocess.PIPE,
56+
text=True,
57+
)
58+
59+
stop_event = threading.Event()
60+
loop_exited = threading.Event()
61+
62+
def reader_loop():
63+
iterations = 0
64+
while not stop_event.is_set():
65+
iterations += 1
66+
if _win32_pipe_has_data(proc.stdout):
67+
proc.stdout.readline()
68+
else:
69+
time.sleep(0.05) # Brief sleep when no data
70+
71+
if iterations > 100: # Safety limit
72+
break
73+
loop_exited.set()
74+
75+
# Start reader thread
76+
reader_thread = threading.Thread(target=reader_loop, daemon=True)
77+
reader_thread.start()
78+
79+
# Wait briefly, then signal stop
80+
time.sleep(0.2)
81+
stop_event.set()
82+
83+
# Thread should exit quickly
84+
reader_thread.join(timeout=1.0)
85+
86+
# Cleanup
87+
proc.kill()
88+
proc.wait()
89+
90+
# The key assertion: thread should have exited
91+
assert loop_exited.is_set(), "Reader loop should have exited when stop event was set"
92+
assert not reader_thread.is_alive(), "Reader thread should not be alive"
93+
94+
def test_handles_closed_pipe(self):
95+
"""Test that function handles closed pipes gracefully."""
96+
proc = subprocess.Popen(
97+
"echo test",
98+
shell=True,
99+
stdout=subprocess.PIPE,
100+
text=True,
101+
)
102+
proc.wait()
103+
proc.stdout.close()
104+
105+
# Should return False, not raise exception
106+
result = _win32_pipe_has_data(proc.stdout)
107+
assert result is False

0 commit comments

Comments
 (0)