From 99724870c828fcdbea243a26146c022143b41eca Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Sun, 21 Jun 2026 19:32:28 +0000 Subject: [PATCH] decode partial stdout/stderr in run_path TimeoutExpired branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a subprocess call exceeds its timeout, subprocess.run returns a TimeoutExpired exception whose stdout/stderr attributes hold the raw bytes the OS had already delivered to the pipe before the kill — even when the call itself was configured with encoding='utf-8' for a uniform str return on the normal completion path. The previous implementation just forwarded those bytes straight into the RunResult, so the result's stdout was bytes and stderr was str (or vice-versa) on timeout, depending on which stream had buffered output. A caller that did 'result.stdout + other_str' would get TypeError on timeout only; on success the same code worked. The decode here matches the encoding passed to subprocess.run so the result is uniformly typed regardless of whether the subprocess finished cleanly or was killed by the timeout. Adds a test that runs a long-running script with a short timeout, captures partial stdout, and asserts RunResult.stdout is a str. --- python/tests/test_runner.py | 24 +++++++++++++++++++++++ python/vscode_common_python_lsp/runner.py | 8 +++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/python/tests/test_runner.py b/python/tests/test_runner.py index ae0783a..3f76baf 100644 --- a/python/tests/test_runner.py +++ b/python/tests/test_runner.py @@ -70,6 +70,30 @@ def test_exit_code_captured(self, tmp_path): ) assert result.exit_code == 42 + def test_timeout_returns_str_stdout_stderr(self, tmp_path): + """When the subprocess exceeds the timeout, partial output is + returned as decoded str (matching the success path) rather than + the raw bytes that subprocess.TimeoutExpired carries by default. + """ + result = run_path( + [ + sys.executable, + "-u", + "-c", + "import time; print('partial line'); time.sleep(5)", + ], + use_stdin=False, + cwd=str(tmp_path), + timeout=1, + ) + # exit_code is None on timeout (process was killed) + assert result.exit_code is None + # stdout/stderr must be str, not bytes, regardless of whether + # the subprocess had produced any output before being killed + assert isinstance(result.stdout, str) + assert isinstance(result.stderr, str) + assert "partial line" in result.stdout + def test_with_env(self, tmp_path): import os diff --git a/python/vscode_common_python_lsp/runner.py b/python/vscode_common_python_lsp/runner.py index f7939d0..ab8d939 100644 --- a/python/vscode_common_python_lsp/runner.py +++ b/python/vscode_common_python_lsp/runner.py @@ -133,7 +133,13 @@ def run_path( timeout=timeout, ) except subprocess.TimeoutExpired as e: - return RunResult(e.stdout or "", e.stderr or "", None) + # subprocess.run with encoding='utf-8' returns decoded str on the + # normal completion path, but TimeoutExpired still carries the + # raw bytes captured from the pipe before decoding — produce + # str here so RunResult is uniformly typed. + partial_out = e.stdout.decode("utf-8", errors="replace") if e.stdout else "" + partial_err = e.stderr.decode("utf-8", errors="replace") if e.stderr else "" + return RunResult(partial_out, partial_err, None) return RunResult(result.stdout, result.stderr, result.returncode)