|
| 1 | +import ctypes |
1 | 2 | import os |
2 | 3 | import select |
3 | 4 | import signal |
@@ -38,6 +39,60 @@ def _truncate_line(line: str) -> str: |
38 | 39 | return line |
39 | 40 |
|
40 | 41 |
|
| 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 | + |
41 | 96 | _AWAITING_USER_INPUT = False |
42 | 97 |
|
43 | 98 | _CONFIRMATION_LOCK = threading.Lock() |
@@ -468,17 +523,35 @@ def read_stdout(): |
468 | 523 |
|
469 | 524 | # Use select to check if data is available (with timeout) |
470 | 525 | 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 |
473 | 529 | 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) |
482 | 555 | except (ValueError, OSError): |
483 | 556 | break |
484 | 557 | else: |
@@ -516,15 +589,35 @@ def read_stderr(): |
516 | 589 | break |
517 | 590 |
|
518 | 591 | 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 |
519 | 595 | 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) |
528 | 621 | except (ValueError, OSError): |
529 | 622 | break |
530 | 623 | else: |
|
0 commit comments