Skip to content

Commit 58ed4d7

Browse files
Jensen ZaneJensen Zane
authored andcommitted
Fix subprocess stream decoding
1 parent 9dfebfe commit 58ed4d7

2 files changed

Lines changed: 98 additions & 12 deletions

File tree

pip_audit/_subprocess.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55

66
import os.path
77
import subprocess
8+
import threading
9+
import time
10+
from codecs import getincrementaldecoder
811
from collections.abc import Sequence
12+
from io import BufferedReader
913
from subprocess import Popen
1014

1115
from ._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+
2739
def 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")

test/test_subprocess.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,65 @@
1+
import sys
2+
13
import pytest
24

35
from pip_audit._subprocess import CalledProcessError, run
46

57

8+
class RecordingState:
9+
def __init__(self):
10+
self.logs = []
11+
12+
def update_state(self, message, logs=None):
13+
self.logs.append(logs)
14+
15+
616
def test_run_raises():
717
with pytest.raises(CalledProcessError):
818
run(["false"])
19+
20+
21+
def test_run_handles_split_multibyte_stdout():
22+
state = RecordingState()
23+
24+
stdout = run(
25+
[
26+
sys.executable,
27+
"-c",
28+
(
29+
"import sys, time; "
30+
"data = 'é'.encode(); "
31+
"sys.stdout.buffer.write(data[:1]); "
32+
"sys.stdout.flush(); "
33+
"time.sleep(0.2); "
34+
"sys.stdout.buffer.write(data[1:]); "
35+
"sys.stdout.flush()"
36+
),
37+
],
38+
log_stdout=True,
39+
state=state,
40+
)
41+
42+
assert stdout == "é"
43+
assert "\ufffd" not in "".join(log or "" for log in state.logs)
44+
45+
46+
def test_run_handles_split_multibyte_stderr():
47+
with pytest.raises(CalledProcessError) as excinfo:
48+
run(
49+
[
50+
sys.executable,
51+
"-c",
52+
(
53+
"import sys, time; "
54+
"data = 'é'.encode(); "
55+
"sys.stderr.buffer.write(data[:1]); "
56+
"sys.stderr.flush(); "
57+
"time.sleep(0.2); "
58+
"sys.stderr.buffer.write(data[1:]); "
59+
"sys.stderr.flush(); "
60+
"sys.exit(1)"
61+
),
62+
],
63+
)
64+
65+
assert excinfo.value.stderr == "é"

0 commit comments

Comments
 (0)