From 005d2d845889eb955f09aec6e74f123130a09e1f Mon Sep 17 00:00:00 2001 From: mwiegand Date: Wed, 6 May 2026 20:30:00 +0200 Subject: [PATCH] fix(host): enforce flush=True to prevent pipeline block buffering --- l4d2host/process.py | 4 ++-- l4d2host/tests/test_process.py | 18 ++++++++++++++++++ l4d2web/services/host_commands.py | 4 ++-- l4d2web/tests/test_host_commands.py | 19 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/l4d2host/process.py b/l4d2host/process.py index e7853b9..4992216 100644 --- a/l4d2host/process.py +++ b/l4d2host/process.py @@ -46,7 +46,7 @@ def run_command( if on_stderr is not None: on_stderr(line) if passthrough: - print(line, file=sys.stderr) + print(line, file=sys.stderr, flush=True) def terminate_process() -> None: emit_stderr_message("cancellation requested; terminating subprocess") @@ -82,7 +82,7 @@ def run_command( if callback is not None: callback(line) if passthrough: - print(line, file=output_stream) + print(line, file=output_stream, flush=True) stream.close() stdout_thread = threading.Thread( diff --git a/l4d2host/tests/test_process.py b/l4d2host/tests/test_process.py index 99c896f..6ec12ea 100644 --- a/l4d2host/tests/test_process.py +++ b/l4d2host/tests/test_process.py @@ -47,3 +47,21 @@ def test_cancelled_command_raises_cancelled_error() -> None: def test_run_command_avoids_runtime_unsafe_nested_annotations() -> None: source = inspect.getsource(run_command) assert "subprocess.Popen[str].stdout" not in source + + +def test_run_command_passthrough_writes_to_sys_streams(monkeypatch: pytest.MonkeyPatch) -> None: + import sys + from io import StringIO + + mock_stdout = StringIO() + mock_stderr = StringIO() + monkeypatch.setattr(sys, "stdout", mock_stdout) + monkeypatch.setattr(sys, "stderr", mock_stderr) + + run_command( + ["python3", "-c", "import sys; print('passed out'); print('passed err', file=sys.stderr)"], + passthrough=True, + ) + + assert mock_stdout.getvalue() == "passed out\n" + assert mock_stderr.getvalue() == "passed err\n" diff --git a/l4d2web/services/host_commands.py b/l4d2web/services/host_commands.py index 340fd2c..a925b9e 100644 --- a/l4d2web/services/host_commands.py +++ b/l4d2web/services/host_commands.py @@ -50,7 +50,7 @@ def run_command( if on_stderr is not None: on_stderr(line) if passthrough: - print(line, file=sys.stderr) + print(line, file=sys.stderr, flush=True) def terminate_process() -> None: emit_stderr_message("cancellation requested; terminating subprocess") @@ -86,7 +86,7 @@ def run_command( if callback is not None: callback(line) if passthrough: - print(line, file=output_stream) + print(line, file=output_stream, flush=True) stream.close() stdout_thread = threading.Thread( diff --git a/l4d2web/tests/test_host_commands.py b/l4d2web/tests/test_host_commands.py index 023792d..f9a6512 100644 --- a/l4d2web/tests/test_host_commands.py +++ b/l4d2web/tests/test_host_commands.py @@ -1,6 +1,25 @@ +import sys +from io import StringIO import pytest +def test_run_command_passthrough_writes_to_sys_streams(monkeypatch: pytest.MonkeyPatch) -> None: + from l4d2web.services.host_commands import run_command + + mock_stdout = StringIO() + mock_stderr = StringIO() + monkeypatch.setattr(sys, "stdout", mock_stdout) + monkeypatch.setattr(sys, "stderr", mock_stderr) + + run_command( + ["python3", "-c", "import sys; print('passed out'); print('passed err', file=sys.stderr)"], + passthrough=True, + ) + + assert mock_stdout.getvalue() == "passed out\n" + assert mock_stderr.getvalue() == "passed err\n" + + def test_run_command_streams_stdout_and_stderr_callbacks() -> None: from l4d2web.services.host_commands import run_command