Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 20 additions & 26 deletions src/stirrup/tools/code_backends/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_local_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down