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)