perf: run local code execution in a plain child interpreter

UnsafeLocalCodeExecutor ran each program as a multiprocessing spawn child,
which had to import this package before it could run a single line, costing
about 2.3 seconds per execution. It now runs the program in a plain child
interpreter, the shape ContainerCodeExecutor already uses, which brings a
trivial program down to about 35 milliseconds. The result now comes from the
child's exit status and pipes rather than a queue the child has to write to,
so a program that dies without reporting anything no longer leaves the agent
waiting forever, and the traceback the model is shown no longer opens with a
frame from inside this package.

One behavior change follows from taking the result from the exit status: a
program calling sys.exit(0) is now reported as having succeeded. It was
previously reported as a failure, because the spawn child raised SystemExit
before it could write to the result queue.

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 967496975
This commit is contained in:
George Weale
2026-08-19 17:38:31 -07:00
committed by Copybara-Service
parent ac8dad2580
commit c244a9c833
2 changed files with 341 additions and 115 deletions
@@ -14,15 +14,12 @@
from __future__ import annotations
from contextlib import redirect_stdout
import io
import logging
import multiprocessing
import os
import queue
import re
import signal
import traceback
import subprocess
import sys
from typing import Any
from pydantic import Field
@@ -39,82 +36,83 @@ logger = logging.getLogger('google_adk.' + __name__)
# escalating to SIGKILL, so that the timeout itself cannot block forever.
_TERMINATE_GRACE_SECONDS = 5
# Runs one program in the child interpreter.
#
# The program arrives on stdin rather than in argv because a single argument is
# capped (at 128 KiB on Linux) and generated programs can carry their own data.
# Reading it leaves stdin at end-of-file, which is what the program would have
# seen before.
#
# The traceback is printed here, without the frame this wrapper contributes, so
# that a failure shows the model its own code and not a file inside this
# package -- a frame it can do nothing about, which then stays in the
# conversation for every later request.
_RUNNER = """
import sys, traceback
def _execute_in_process(
code: str,
globals_: dict[str, Any],
result_queue: multiprocessing.Queue[tuple[str, str | None]],
) -> None:
"""Executes code in a separate process and puts result in queue."""
# Detach into a new session/process group before running anything, so that a
# timed-out execution can be killed together with everything it spawned.
if hasattr(os, 'setsid'):
try:
os.setsid()
except OSError:
logger.debug('Could not detach the execution process group.')
_run_name = sys.argv[1]
del sys.argv[1:]
stdout = io.StringIO()
error = None
try:
with redirect_stdout(stdout):
exec(code, globals_, globals_)
except BaseException:
error = traceback.format_exc()
result_queue.put((stdout.getvalue(), error))
_globals = {'__name__': _run_name} if _run_name else {}
_source = sys.stdin.buffer.read().decode('utf-8')
try:
exec(compile(_source, '<code>', 'exec'), _globals, _globals)
except SystemExit:
# The program chose its own exit status, so let it stand rather than
# reporting a deliberate clean exit as a failure.
raise
except BaseException as exc:
_tb = exc.__traceback__
traceback.print_exception(
type(exc), exc, _tb.tb_next if _tb else None, file=sys.stderr
)
sys.exit(1)
"""
def _execution_group(
process: multiprocessing.process.BaseProcess,
) -> int | None:
"""Returns the group the execution detached into, or None if it has not."""
if process.pid is None or not hasattr(os, 'killpg'):
return None
try:
group = os.getpgid(process.pid)
# Only report the group once the execution has detached into its own;
# otherwise the group is still ours and signalling it would take down the
# agent along with the code it is running.
return group if group != os.getpgid(0) else None
except OSError:
return None
def _run_name(code: str) -> str:
"""Returns the `__name__` the code should run under, or '' for none."""
if re.search(r"if\s+__name__\s*==\s*['\"]__main__['\"]", code):
return '__main__'
return ''
def _signal_group(group: int, sig: int) -> None:
"""Signals every process left in a group, tolerating an empty one."""
if not hasattr(os, 'killpg'):
return
try:
os.killpg(group, sig)
except OSError:
logger.debug('Could not signal the execution process group.')
def _kill_execution(process: multiprocessing.process.BaseProcess) -> None:
"""Kills a timed-out execution along with any process it spawned."""
# Resolved up front: once the execution process has been reaped its group can
# no longer be looked up through it, and the group is what holds whatever the
# code spawned.
group = _execution_group(process)
def _kill_execution(process: subprocess.Popen[str]) -> tuple[str, str]:
"""Kills a timed-out execution, returning what it wrote before it died."""
# The execution leads its own process group, so its pid is the group that
# holds whatever it spawned. `terminate` and `kill` below reach the execution
# itself on the platforms that have no group to signal.
group = process.pid
# SIGTERM first, so the code and its children get the same grace period the
# execution process itself gets before anything is killed outright.
if group is not None:
_signal_group(group, signal.SIGTERM)
_signal_group(group, signal.SIGTERM)
process.terminate()
process.join(_TERMINATE_GRACE_SECONDS)
try:
process.wait(_TERMINATE_GRACE_SECONDS)
except subprocess.TimeoutExpired:
pass
# Escalate unconditionally: the execution process exiting says nothing about
# a child of it that is ignoring SIGTERM.
if group is not None:
_signal_group(group, signal.SIGKILL)
if process.is_alive():
process.kill()
process.join()
def _prepare_globals(code: str, globals_: dict[str, Any]) -> None:
"""Prepare globals for code execution, injecting __name__ if needed."""
if re.search(r"if\s+__name__\s*==\s*['\"]__main__['\"]", code):
globals_['__name__'] = '__main__'
# a child of it that is ignoring SIGTERM, and such a child holds the output
# pipes open besides.
_signal_group(group, signal.SIGKILL)
process.kill()
try:
return process.communicate(timeout=_TERMINATE_GRACE_SECONDS)
except subprocess.TimeoutExpired:
return '', ''
class UnsafeLocalCodeExecutor(BaseCodeExecutor):
@@ -144,33 +142,56 @@ class UnsafeLocalCodeExecutor(BaseCodeExecutor):
code_execution_input: CodeExecutionInput,
) -> CodeExecutionResult:
logger.debug('Executing code:\n```\n%s\n```', code_execution_input.code)
# Execute the code.
globals_: dict[str, Any] = {}
_prepare_globals(code_execution_input.code, globals_)
ctx = multiprocessing.get_context('spawn')
result_queue: multiprocessing.Queue[tuple[str, str | None]] = ctx.Queue()
process = ctx.Process(
target=_execute_in_process,
args=(code_execution_input.code, globals_, result_queue),
daemon=True,
env = dict(os.environ)
# A plain child interpreter starts with its own import path, so pass the
# agent's along: code importing a module from the application resolved
# before this and still does.
env['PYTHONPATH'] = os.pathsep.join(p for p in sys.path if p)
# Pin the child's output encoding. Its stdout is a pipe, so it would
# otherwise encode with the host locale and a program printing non-ASCII
# under a non-UTF-8 locale would die encoding its own output.
env['PYTHONIOENCODING'] = 'utf-8'
process = subprocess.Popen(
[sys.executable, '-c', _RUNNER, _run_name(code_execution_input.code)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding='utf-8',
errors='replace',
env=env,
# Its own session, so a timeout can take down everything the code
# started and nothing else. Ignored on Windows, as `os.killpg` is.
start_new_session=True,
)
process.start()
output = ''
error = ''
timed_out = False
try:
output, err = result_queue.get(timeout=self.timeout_seconds)
process.join()
if err:
error = err
except queue.Empty:
_kill_execution(process)
error = f'Code execution timed out after {self.timeout_seconds} seconds.'
output, error = process.communicate(
input=code_execution_input.code, timeout=self.timeout_seconds
)
except subprocess.TimeoutExpired:
output, error = _kill_execution(process)
timed_out = True
if timed_out:
# Appended rather than assigned: whatever the code wrote before it was
# killed is still the useful diagnostic, but on its own it would hide
# the fact that the run was cut short.
note = f'Code execution timed out after {self.timeout_seconds} seconds.'
error = f'{error}\n{note}' if error else note
elif process.returncode == 0:
# A non-empty stderr is what marks the result failed and drives the retry
# counter, so it has to mean the program failed rather than that the
# program wrote a warning. The exit status is what says which happened.
error = ''
elif not error:
# The code died without saying why: a signal, or a call to `os._exit`.
# Reporting nothing would show the model a clean run.
error = f'Code execution exited with status {process.returncode}.'
# Collect the final result.
result_queue.close()
result_queue.join_thread()
return CodeExecutionResult(
stdout=output,
stderr=error,
@@ -12,9 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import multiprocessing
import os
import signal
import subprocess
import textwrap
import time
from unittest.mock import MagicMock
@@ -49,6 +49,31 @@ def _is_alive(pid: int) -> bool:
return state != "Z"
def _execute_within(
executor: UnsafeLocalCodeExecutor,
invocation_context: InvocationContext,
code: str,
seconds: float,
) -> CodeExecutionResult:
"""Executes `code` under a wall-clock bound.
Several callers below cover code that dies without reporting a result, which
is precisely the case the old executor could wait on forever. The bound is
the executor's own timeout rather than a watchdog around it: a child that
dies closes its pipes, so the wait ends on its own, and passing the timeout
through also exercises the path that enforces it.
"""
started = time.monotonic()
result = executor.execute_code(
invocation_context, CodeExecutionInput(code=code)
)
elapsed = time.monotonic() - started
assert (
elapsed < seconds * 4
), f"the executor took {elapsed:.1f}s against a {seconds}s timeout"
return result
@pytest.fixture
def mock_invocation_context() -> InvocationContext:
"""Provides a mock InvocationContext."""
@@ -156,6 +181,185 @@ class TestUnsafeLocalCodeExecutor:
assert result.stdout == ""
assert "Code execution timed out after 1 seconds." in result.stderr
def test_execute_code_main_guard_runs(
self, mock_invocation_context: InvocationContext
):
"""Code guarded on `__main__` runs, as it did in the previous child."""
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(code=textwrap.dedent("""
if __name__ == '__main__':
print('guarded')
"""))
result = executor.execute_code(mock_invocation_context, code_input)
assert result.stdout == "guarded\n"
assert result.stderr == ""
def test_execute_code_without_main_guard_is_not_main(
self, mock_invocation_context: InvocationContext
):
"""Code that does not ask to be `__main__` is not given the name."""
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(code="print(globals().get('__name__'))")
result = executor.execute_code(mock_invocation_context, code_input)
assert result.stdout == "None\n"
def test_execute_code_separates_stdout_from_stderr(
self, mock_invocation_context: InvocationContext
):
"""Each stream lands in its own field rather than being interleaved."""
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(code=textwrap.dedent("""
import sys
sys.stdout.write('to out')
sys.stderr.write('to err')
sys.exit(0)
"""))
result = executor.execute_code(mock_invocation_context, code_input)
assert result.stdout == "to out"
# The program wrote to stderr but succeeded. A non-empty stderr is what
# marks a result failed and drives the retry counter, so a warning must not
# be reported as a failure.
assert result.stderr == ""
def test_execute_code_nonzero_exit_is_reported_as_a_failure(
self, mock_invocation_context: InvocationContext
):
"""A program that exits non-zero fails even when it says nothing."""
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(code="import sys\nsys.exit(3)")
result = executor.execute_code(mock_invocation_context, code_input)
assert result.stdout == ""
assert result.stderr == "Code execution exited with status 3."
def test_execute_code_reports_a_crash_rather_than_hanging(
self, mock_invocation_context: InvocationContext
):
"""Code that exits without reporting anything still returns a result."""
executor = UnsafeLocalCodeExecutor()
result = _execute_within(
executor,
mock_invocation_context,
"import os\nprint('before', flush=True)\nos._exit(3)",
seconds=30,
)
assert result.stdout == "before\n"
assert result.stderr == "Code execution exited with status 3."
@pytest.mark.skipif(
not hasattr(signal, "SIGKILL"),
reason="Death by signal is checked on POSIX only.",
)
def test_execute_code_reports_death_by_signal(
self, mock_invocation_context: InvocationContext
):
"""Code killed outright -- by a segfault or the OOM killer -- returns."""
executor = UnsafeLocalCodeExecutor()
result = _execute_within(
executor,
mock_invocation_context,
"import os, signal\nos.kill(os.getpid(), signal.SIGKILL)",
seconds=30,
)
assert result.stdout == ""
assert result.stderr == (
f"Code execution exited with status {-signal.SIGKILL}."
)
def test_execute_code_traceback_omits_this_module(
self, mock_invocation_context: InvocationContext
):
"""A failure shows the model its own code, not a frame from this package."""
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(
code="def divide():\n return 1 / 0\n\ndivide()"
)
result = executor.execute_code(mock_invocation_context, code_input)
assert "ZeroDivisionError" in result.stderr
assert "unsafe_local_code_executor" not in result.stderr
# Both frames of the executed code survive; only the wrapper's is dropped.
assert result.stderr.count('File "<code>"') == 2
def test_execute_code_preserves_unicode(
self, mock_invocation_context: InvocationContext
):
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(code="print('你好, café')")
result = executor.execute_code(mock_invocation_context, code_input)
assert result.stdout == "你好, café\n"
assert result.stderr == ""
def test_execute_code_output_encoding_does_not_follow_the_host_locale(
self, mock_invocation_context: InvocationContext
):
"""Otherwise a program printing non-ASCII dies encoding its own output."""
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(
code="import sys\nprint(sys.stdout.encoding)"
)
result = executor.execute_code(mock_invocation_context, code_input)
assert result.stdout.strip().lower() == "utf-8"
def test_execute_code_large_output(
self, mock_invocation_context: InvocationContext
):
"""Output far larger than a pipe buffer is read out rather than deadlocked."""
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(code="print('x' * 1000000)")
result = _execute_within(
executor, mock_invocation_context, code_input.code, seconds=60
)
assert result.stdout == "x" * 1000000 + "\n"
assert result.stderr == ""
def test_execute_code_large_program(
self, mock_invocation_context: InvocationContext
):
"""A program carrying its own data is not capped by an argument limit."""
executor = UnsafeLocalCodeExecutor()
payload = "a" * 300000
code_input = CodeExecutionInput(
code=f"data = {payload!r}\nprint(len(data))"
)
result = executor.execute_code(mock_invocation_context, code_input)
assert result.stdout == f"{len(payload)}\n"
assert result.stderr == ""
def test_execute_code_imports_resolve_from_the_agent_path(
self, mock_invocation_context: InvocationContext
):
"""Code importing what the application can import still resolves."""
executor = UnsafeLocalCodeExecutor()
code_input = CodeExecutionInput(code="import google.adk\nprint('imported')")
result = _execute_within(
executor, mock_invocation_context, code_input.code, seconds=120
)
assert result.stdout == "imported\n"
assert result.stderr == ""
def test_kill_execution_signals_group_before_killing_it(self, monkeypatch):
"""The group gets SIGTERM and its grace period before SIGKILL."""
signalled = []
@@ -163,35 +367,51 @@ class TestUnsafeLocalCodeExecutor:
unsafe_local_code_executor.os,
"killpg",
lambda group, sig: signalled.append((group, sig)),
)
monkeypatch.setattr(
unsafe_local_code_executor,
"_execution_group",
lambda process: 4321,
raising=False,
)
process = MagicMock()
process.is_alive.return_value = False
process.pid = 4321
process.terminate.side_effect = lambda: signalled.append(("child", "term"))
process.kill.side_effect = lambda: signalled.append(("child", "kill"))
process.communicate.return_value = ("out", "err")
unsafe_local_code_executor._kill_execution(process)
assert unsafe_local_code_executor._kill_execution(process) == ("out", "err")
assert signalled == [
(4321, signal.SIGTERM),
("child", "term"),
(4321, signal.SIGKILL),
("child", "kill"),
]
process.join.assert_any_call(
process.wait.assert_called_once_with(
unsafe_local_code_executor._TERMINATE_GRACE_SECONDS
)
def test_kill_execution_gives_up_on_pipes_that_never_close(self, monkeypatch):
"""A pipe held open by something unkillable does not block the agent."""
monkeypatch.setattr(
unsafe_local_code_executor.os,
"killpg",
lambda group, sig: None,
raising=False,
)
process = MagicMock()
process.pid = 4321
process.wait.side_effect = subprocess.TimeoutExpired("cmd", 5)
process.communicate.side_effect = subprocess.TimeoutExpired("cmd", 5)
assert unsafe_local_code_executor._kill_execution(process) == ("", "")
@pytest.mark.skipif(
not hasattr(os, "killpg")
or not hasattr(os, "fork")
or not os.path.isdir("/proc"),
reason="Process-group teardown is checked on POSIX with /proc only.",
)
def test_kill_execution_kills_what_the_code_spawned(self, tmp_path):
"""Killing a live execution takes the processes it spawned with it."""
def test_timeout_kills_what_the_code_spawned(
self, mock_invocation_context: InvocationContext, tmp_path
):
"""A timed-out execution takes the processes it spawned with it."""
pid_file = tmp_path / "spawned.pid"
# Forked rather than spawned through `sys.executable`, so the descendant
# exists within milliseconds and the test never waits on interpreter
@@ -208,29 +428,18 @@ class TestUnsafeLocalCodeExecutor:
f.write(str(spawned))
time.sleep(60)
""")
ctx = multiprocessing.get_context("spawn")
result_queue = ctx.Queue()
process = ctx.Process(
target=unsafe_local_code_executor._execute_in_process,
args=(code, {}, result_queue),
daemon=True,
)
process.start()
executor = UnsafeLocalCodeExecutor(timeout_seconds=5)
spawned_pid = None
try:
# Waiting for the pid to be written rather than for a fixed duration:
# the only thing that has to have happened is the fork. The file exists
# from the moment it is opened, so its content is what is polled for.
deadline = time.time() + 30
while time.time() < deadline and not _written_pid(pid_file):
time.sleep(0.05)
result = _execute_within(
executor, mock_invocation_context, code, seconds=60
)
assert "Code execution timed out after 5 seconds." in result.stderr
spawned_pid = _written_pid(pid_file)
if spawned_pid is None:
pytest.skip("this environment could not start the execution process")
unsafe_local_code_executor._kill_execution(process)
assert not process.is_alive()
deadline = time.time() + 10
while time.time() < deadline and _is_alive(spawned_pid):
time.sleep(0.05)
@@ -241,7 +450,3 @@ class TestUnsafeLocalCodeExecutor:
os.kill(spawned_pid, signal.SIGKILL)
except OSError:
pass
if process.is_alive():
process.kill()
process.join()
result_queue.close()