Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/watts/fileutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,15 @@ def read_async(fd):

if p.poll() is not None:
break

# Drain any data remaining in the pipe buffers after the process exits.
# With O_NONBLOCK, a single read() per iteration may not consume all buffered
# bytes (the kernel returns what fits in one syscall). Once the write end of
# the pipe is closed (process exited) read_async returns b'' on EOF rather
# than raising EAGAIN, so this loop terminates cleanly.
for fd, stream in ((p.stdout, sys.stdout), (p.stderr, sys.stderr)):
while True:
chunk = read_async(fd)
if not chunk:
break
stream.write(chunk.decode())
22 changes: 22 additions & 0 deletions tests/test_fileutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import subprocess
import sys

import pytest

from watts.fileutils import tee_stdout, tee_stderr, run


Expand Down Expand Up @@ -55,3 +57,23 @@ def test_run(run_in_tmpdir):
run(['env'])
assert f.getvalue() == file_output


@pytest.mark.skipif(sys.platform == 'win32', reason="O_NONBLOCK not available on Windows")
def test_run_captures_all_output(run_in_tmpdir):
# Emit enough output to fill multiple pipe-buffer reads (typically 64 KB),
# then verify every byte is captured. This is a regression test for the
# post-exit drain: without draining after p.poll() returns, the last chunk
# of buffered data could be silently dropped.
line = "x" * 79 + "\n" # 80 bytes per line
n_lines = 2000 # 160 000 bytes — well past one pipe-buffer read
script = f"import sys; [sys.stdout.write({line!r}) for _ in range({n_lines})]"

with redirect_stdout(io.StringIO()) as f:
run([sys.executable, '-c', script])

captured = f.getvalue()
assert captured == line * n_lines, (
f"Expected {n_lines * 80} bytes but got {len(captured)}; "
"output was likely truncated due to missing post-exit drain"
)