Skip to content

Commit e313258

Browse files
gavenrenByron
authored andcommitted
fix: copy stream data through short reads
`stream_copy` stopped after any read shorter than `chunk_size`, truncating valid unbuffered streams. Continue until an empty chunk reports end of input, preserving the existing writer contract and byte count. Add binary/text short-read and empty-input regressions, plus a synchronized real POSIX pipe reproduction. The regressions fail before the fix; utility and typing modules pass with both CLI and Gix backends (126 passed, 39 skipped each). Applicable pre-commit checks pass. Assisted-by: OpenAI Codex (model: unknown, autonomous)
1 parent 44ad498 commit e313258

2 files changed

Lines changed: 62 additions & 1 deletion

File tree

‎git/util.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ def stream_copy(source: SupportsRead[AnyStr], destination: SupportsWrite[AnyStr]
219219
"""Copy all data from the `source` stream into the `destination` stream in chunks
220220
of size `chunk_size`.
221221
222+
Keep reading after short reads until the source returns an empty chunk.
223+
222224
:return:
223225
Number of bytes written
224226
"""
@@ -227,7 +229,7 @@ def stream_copy(source: SupportsRead[AnyStr], destination: SupportsWrite[AnyStr]
227229
chunk = source.read(chunk_size)
228230
destination.write(chunk)
229231
br += len(chunk)
230-
if len(chunk) < chunk_size:
232+
if not chunk:
231233
break
232234
# END reading output stream
233235
return br

‎test/test_util.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import threading
1414
import time
1515
from datetime import datetime
16+
from io import BytesIO, StringIO
1617
from unittest import SkipTest, mock
1718

1819
import ddt
@@ -41,6 +42,7 @@
4142
join_path,
4243
remove_password_if_present,
4344
rmtree,
45+
stream_copy,
4446
)
4547
from test.cleanup import cleanup_directory
4648
from test.lib import TestBase, requires_symlinks, with_rw_repo
@@ -67,6 +69,63 @@ def test_join_path_keeps_later_tokens_relative(parts, expected):
6769
assert join_path(*parts) == expected
6870

6971

72+
@pytest.mark.parametrize(
73+
"parts, expected",
74+
[([b"ab", b"c", b"def"], b"abcdef"), (["ab", "c", "def"], "abcdef"), ([], b""), ([], "")],
75+
)
76+
def test_stream_copy_waits_for_an_empty_read(parts, expected):
77+
chunks = iter(parts)
78+
79+
class Reader:
80+
def read(self, size):
81+
chunk = next(chunks, expected[:0])
82+
assert len(chunk) <= size
83+
return chunk
84+
85+
destination = BytesIO() if isinstance(expected, bytes) else StringIO()
86+
assert stream_copy(Reader(), destination, chunk_size=4) == len(expected)
87+
assert destination.getvalue() == expected
88+
89+
90+
@pytest.mark.skipif(sys.platform == "win32", reason="requires POSIX unbuffered pipe reads")
91+
def test_stream_copy_keeps_reading_an_unbuffered_pipe():
92+
read_fd, write_fd = os.pipe()
93+
first_copied = threading.Event()
94+
errors = []
95+
96+
class Destination(BytesIO):
97+
def write(self, data):
98+
result = super().write(data)
99+
if data:
100+
first_copied.set()
101+
return result
102+
103+
def produce():
104+
try:
105+
os.write(write_fd, b"first-")
106+
if not first_copied.wait(5):
107+
raise RuntimeError("first pipe chunk was not copied")
108+
os.write(write_fd, b"second")
109+
except Exception as error:
110+
errors.append(error)
111+
finally:
112+
os.close(write_fd)
113+
114+
destination = Destination()
115+
with os.fdopen(read_fd, "rb", buffering=0) as source:
116+
thread = threading.Thread(target=produce)
117+
thread.start()
118+
try:
119+
copied = stream_copy(source, destination, chunk_size=1024)
120+
finally:
121+
first_copied.set()
122+
thread.join(5)
123+
assert not thread.is_alive()
124+
assert not errors
125+
assert copied == len(b"first-second")
126+
assert destination.getvalue() == b"first-second"
127+
128+
70129
@pytest.fixture
71130
def tmp_path(request):
72131
# Unreadable files and symlinks cannot be copied by native fixture tooling.

0 commit comments

Comments
 (0)