55
66import os .path
77import subprocess
8+ import threading
9+ import time
10+ from codecs import getincrementaldecoder
811from collections .abc import Sequence
12+ from io import BufferedReader
913from subprocess import Popen
1014
1115from ._state import AuditState
@@ -24,6 +28,14 @@ def __init__(self, msg: str, *, stderr: str) -> None:
2428 self .stderr = stderr
2529
2630
31+ def _read_stream (stream : BufferedReader , output : bytearray ) -> None :
32+ """
33+ Read a subprocess stream into the given output buffer.
34+ """
35+ while chunk := stream .read (8192 ):
36+ output .extend (chunk )
37+
38+
2739def run (args : Sequence [str ], * , log_stdout : bool = False , state : AuditState = AuditState ()) -> str :
2840 """
2941 Execute the given arguments.
@@ -39,29 +51,46 @@ def run(args: Sequence[str], *, log_stdout: bool = False, state: AuditState = Au
3951 # state updates, so we trim the first argument down to its basename.
4052 pretty_args = " " .join ([os .path .basename (args [0 ]), * args [1 :]])
4153
42- terminated = False
43- stdout = b""
44- stderr = b""
54+ stdout = bytearray ()
55+ stderr = bytearray ()
4556
4657 # Run the process with unbuffered I/O, to make the poll-and-read loop below
4758 # more responsive.
4859 with Popen (args , bufsize = 0 , stdout = subprocess .PIPE , stderr = subprocess .PIPE ) as process :
49- # NOTE: We use `poll()` to control this loop instead of the `read()` call
50- # to prevent deadlocks. Similarly, `read(size)` will return an empty bytes
51- # once `stdout` hits EOF, so we don't have to worry about that blocking.
52- while not terminated :
53- terminated = process .poll () is not None
54- stdout += process .stdout .read () # type: ignore
55- stderr += process .stderr .read () # type: ignore
60+ assert process .stdout is not None
61+ assert process .stderr is not None
62+
63+ stdout_thread = threading .Thread (target = _read_stream , args = (process .stdout , stdout ))
64+ stderr_thread = threading .Thread (target = _read_stream , args = (process .stderr , stderr ))
65+ stdout_thread .start ()
66+ stderr_thread .start ()
67+
68+ stdout_decoder = getincrementaldecoder ("utf-8" )(errors = "replace" )
69+ stdout_decoded = ""
70+ stdout_decoded_len = 0
71+
72+ while process .poll () is None :
73+ stdout_decoded += stdout_decoder .decode (bytes (stdout [stdout_decoded_len :]))
74+ stdout_decoded_len = len (stdout )
5675 state .update_state (
5776 f"Running { pretty_args } " ,
58- stdout . decode ( errors = "replace" ) if log_stdout else None ,
77+ stdout_decoded if log_stdout else None ,
5978 )
79+ time .sleep (0.1 )
80+
81+ stdout_thread .join ()
82+ stderr_thread .join ()
83+
84+ stdout_decoded += stdout_decoder .decode (bytes (stdout [stdout_decoded_len :]), final = True )
85+ state .update_state (
86+ f"Running { pretty_args } " ,
87+ stdout_decoded if log_stdout else None ,
88+ )
6089
6190 if process .returncode != 0 :
6291 raise CalledProcessError (
6392 f"{ pretty_args } exited with { process .returncode } " ,
64- stderr = stderr .decode (errors = "replace" ),
93+ stderr = stderr .decode ("utf-8" , errors = "replace" ),
6594 )
6695
6796 return stdout .decode ("utf-8" , errors = "replace" )
0 commit comments