diff --git a/src/stirrup/tools/code_backends/local.py b/src/stirrup/tools/code_backends/local.py index 75fee65..c409904 100644 --- a/src/stirrup/tools/code_backends/local.py +++ b/src/stirrup/tools/code_backends/local.py @@ -327,36 +327,30 @@ async def run_command(self, cmd: str, *, timeout: int | None = None) -> CommandR # on timeout. Without it, process.kill() only terminates the # root process, leaving grandchildren reparented to init (same # bug shape as the docker orphan leak that motivated this PR). - process = await anyio.open_process( - argv if argv is not None else ["bash", "-c", cmd], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=self._temp_dir, - start_new_session=True, - ) - - # Read all output from streams concurrently - stdout_chunks: list[bytes] = [] - stderr_chunks: list[bytes] = [] - - async def read_stdout() -> None: - if process.stdout: - stdout_chunks.extend([chunk async for chunk in process.stdout]) - - async def read_stderr() -> None: - if process.stderr: - stderr_chunks.extend([chunk async for chunk in process.stderr]) - - async with anyio.create_task_group() as tg: - tg.start_soon(read_stdout) - tg.start_soon(read_stderr) + # Use regular temporary files instead of pipes. A backgrounded + # shell command inherits stdout/stderr, so pipe readers cannot see + # EOF when the shell exits and would keep this call waiting for the + # background process. Files preserve the shell's output while letting + # us wait only for the command process itself. + with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file: + process = await anyio.open_process( + argv if argv is not None else ["bash", "-c", cmd], + stdout=stdout_file, + stderr=stderr_file, + cwd=self._temp_dir, + start_new_session=True, + ) + await process.wait() - await process.wait() + stdout_file.seek(0) + stderr_file.seek(0) + stdout = stdout_file.read().decode("utf-8", errors="replace") + stderr = stderr_file.read().decode("utf-8", errors="replace") return CommandResult( exit_code=process.returncode or 0, - stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"), - stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"), + stdout=stdout, + stderr=stderr, ) except TimeoutError: diff --git a/tests/test_local_execution.py b/tests/test_local_execution.py index 97558fd..33e39fa 100644 --- a/tests/test_local_execution.py +++ b/tests/test_local_execution.py @@ -60,6 +60,18 @@ async def test_run_command_timeout(self) -> None: assert result.error_kind == "timeout" assert "timed out" in result.stderr.lower() + async def test_run_command_backgrounded_compound_command_returns_without_waiting(self) -> None: + """A backgrounded command list must not keep captured output streams open.""" + provider = LocalCodeExecToolProvider() + + async with provider as _: + result = await provider.run_command('echo "hi" && touch ready && sleep 2 > /dev/null 2>&1 & while [ ! -f ready ]; do sleep 0.01; done', timeout=1) + + assert result.exit_code == 0 + assert result.stdout == "hi\n" + assert result.stderr == "" + assert result.error_kind is None + async def test_run_command_timeout_kills_descendants(self) -> None: """Regression: a timed-out command must not leave orphaned grandchildren.