From e1b640debb203db5ea7aff6351b57a73f6133129 Mon Sep 17 00:00:00 2001 From: Vamsi-2581 <245859299+Vamsi-2581@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:32:10 +0530 Subject: [PATCH 1/3] fix: return after backgrounded shell command exits --- src/stirrup/tools/code_backends/local.py | 46 +++++++++++------------- 1 file changed, 20 insertions(+), 26 deletions(-) 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: From d198a00a85df8cea95113c019bd090b410942982 Mon Sep 17 00:00:00 2001 From: Vamsi-2581 <245859299+Vamsi-2581@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:32:17 +0530 Subject: [PATCH 2/3] test: cover backgrounded compound shell commands --- tests/test_local_execution.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_local_execution.py b/tests/test_local_execution.py index 97558fd..688f1cc 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" && sleep 2 > /dev/null 2>&1 &', 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. From 4c0413d0e0e4f0b7d3f3ae27f4f56a3ffea51067 Mon Sep 17 00:00:00 2001 From: Vamsi-2581 <245859299+Vamsi-2581@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:32:55 +0530 Subject: [PATCH 3/3] test: synchronize background command output capture --- tests/test_local_execution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_local_execution.py b/tests/test_local_execution.py index 688f1cc..33e39fa 100644 --- a/tests/test_local_execution.py +++ b/tests/test_local_execution.py @@ -65,7 +65,7 @@ async def test_run_command_backgrounded_compound_command_returns_without_waiting provider = LocalCodeExecToolProvider() async with provider as _: - result = await provider.run_command('echo "hi" && sleep 2 > /dev/null 2>&1 &', timeout=1) + 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"