Skip to content

Commit d8692ba

Browse files
caohy1988claude
andcommitted
docs: Fix container timeout DoS, pkill scope, stale recommendations
- Redesign container timeout: run exec_start in thread + join(timeout), kill via host-side os.kill(host_pid) instead of in-container pkill. Eliminates blocking exec_start DoS and overbroad pkill -f pattern. - Remove contradictory "RECOMMENDED" label from Option C; Option A (persistent process) is the single recommended approach. - Remove Windows implementation guidance (contradicts Non-Goals §2.4); raise NotImplementedError instead. - Guard resource import in LocalSandboxCodeExecutor wrapper script for platforms where resource module is unavailable. - Fix remote executor timeout to use synchronous thread+join, not asyncio.wait_for (execute_code APIs are synchronous). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8ca1111 commit d8692ba

1 file changed

Lines changed: 122 additions & 98 deletions

File tree

docs/design/code_executor_enhancements.md

Lines changed: 122 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,15 @@ running indefinitely after the join timeout expires.
266266

267267
**Primary approach: Docker exec kill via `exec_inspect` + PID kill.**
268268

269+
The key constraint is that `exec_start` is a **blocking** call — a
270+
timer thread cannot unblock it if the in-container kill fails. The
271+
correct design runs `exec_start` in a worker thread so the caller
272+
can enforce the timeout via `thread.join(timeout)`, then uses the
273+
**host-side Docker API** to kill the exec'd process by its host PID.
274+
269275
```python
276+
import os
277+
import signal
270278
import threading
271279

272280
def execute_code(self, invocation_context, code_execution_input):
@@ -282,76 +290,79 @@ def execute_code(self, invocation_context, code_execution_input):
282290
['python3', '-c', code_execution_input.code],
283291
)['Id']
284292

285-
# Start a timer to kill the exec on timeout.
286-
#
287-
# NOTE on PID namespaces: exec_inspect().Pid returns the
288-
# host-namespace PID of the exec'd process. Passing that PID
289-
# to `kill` inside the container will not match (different PID
290-
# namespace). Instead, we use the Docker API to kill from the
291-
# host side, or find the container-namespace PID.
292-
timer = None
293-
timed_out = threading.Event()
294-
if timeout is not None:
295-
def _kill_exec():
296-
timed_out.set()
293+
# Run exec_start in a thread so we can enforce timeout
294+
# from the calling thread.
295+
result_holder = {}
296+
297+
def _run_exec():
298+
result_holder['output'] = (
299+
self._client.api.exec_start(exec_id, demux=True)
300+
)
301+
302+
thread = threading.Thread(target=_run_exec, daemon=True)
303+
thread.start()
304+
thread.join(timeout=timeout)
305+
306+
if thread.is_alive():
307+
# Timeout exceeded — kill from host side.
308+
# exec_inspect returns the host-namespace PID, which
309+
# is the correct PID for os.kill() on the host.
310+
# (Killing from inside the container would require
311+
# the container-namespace PID, which is different.)
312+
try:
313+
info = self._client.api.exec_inspect(exec_id)
314+
host_pid = info.get('Pid', 0)
315+
if host_pid > 0:
316+
os.kill(host_pid, signal.SIGKILL)
317+
except (ProcessLookupError, PermissionError):
318+
pass # Process already exited
319+
except Exception:
320+
# Last resort: restart the container
297321
try:
298-
# Approach: find exec'd process by its command
299-
# inside the container's PID namespace, then kill.
300-
# `exec_inspect` gives us the command; `pkill -f`
301-
# matches it inside the container.
302-
self._container.exec_run(
303-
['pkill', '-9', '-f', 'python3 -c'],
304-
detach=True,
305-
)
322+
self._container.restart(timeout=1)
306323
except Exception:
307324
pass
308-
# If the above fails or is insufficient, the next
309-
# execute_code() call will detect the zombie and
310-
# restart the container.
311-
timer = threading.Timer(timeout, _kill_exec)
312-
timer.start()
313-
314-
try:
315-
output = self._client.api.exec_start(exec_id, demux=True)
316-
finally:
317-
if timer is not None:
318-
timer.cancel()
319-
320-
if timed_out.is_set():
325+
321326
return CodeExecutionResult(
322327
stderr=f'Execution timed out after {timeout}s'
323328
)
329+
330+
output = result_holder.get('output')
324331
# ... parse output as before
325332
```
326333

327-
**PID namespace considerations:**
328-
- `exec_inspect(exec_id)['Pid']` returns the **host-namespace** PID.
329-
Running `kill <host-pid>` inside the container operates in the
330-
**container PID namespace** and will target a different (or
331-
non-existent) process. This is a common Docker pitfall.
332-
- The correct approaches are:
333-
1. **`pkill -f` inside container** — Match the exec'd command string
334-
within the container's PID namespace. Works for `python3 -c` but
335-
may be too broad if multiple execs are running concurrently.
336-
2. **Host-side kill via `docker top` + `os.kill`** — Use
337-
`container.top()` to map container PIDs to host PIDs, then
338-
`os.kill(host_pid, 9)` from the host. More precise but requires
339-
host-level permissions.
340-
3. **Container restart** — Simplest and most reliable. Acceptable
341-
when stateful mode is not in use.
342-
343-
**Recovery after timeout kill:**
344-
- **Stateless mode:** No recovery needed. Next `execute_code()` call
345-
starts a fresh process in the same container.
346-
- **Stateful mode (cumulative replay):** The timed-out block is NOT
347-
appended to history (append-after-success invariant), so replay
348-
remains clean. However, if `pkill` killed a persistent interpreter
349-
(Phase 2 / Option A), the executor must detect this and restart
350-
it before the next call.
351-
352-
**Recommendation:** Use `pkill -f` as the primary approach for Phase 1.
353-
Migrate to host-side kill or container restart for more robust cleanup
354-
in Phase 2 when persistent processes are introduced.
334+
**Why this design:**
335+
336+
1. **`exec_start` in a thread + `join(timeout)`** — The caller is
337+
never blocked longer than `timeout` seconds, regardless of whether
338+
the kill succeeds. This resolves the primary DoS risk.
339+
340+
2. **Host-side `os.kill(host_pid, SIGKILL)`**`exec_inspect()`
341+
returns the **host-namespace** PID. Using `os.kill()` from the
342+
host process operates in the host PID namespace, so the PID
343+
matches correctly. This avoids:
344+
- The PID namespace mismatch of killing from inside the container
345+
- The overbroad pattern matching of `pkill -f` (which can hit
346+
concurrent execs or unrelated Python processes)
347+
- Dependency on `procps`/`pkill` being installed in the image
348+
349+
3. **Container restart as last resort** — If `os.kill` fails (e.g.,
350+
insufficient permissions when Docker runs rootless), restart the
351+
container. This is the most reliable fallback but destroys
352+
in-container state.
353+
354+
**Permissions:** `os.kill()` on the host PID requires the ADK process
355+
to run as the same user that started the container (or as root). This
356+
is the normal case for local Docker usage. For rootless Docker or
357+
restricted environments, the container-restart fallback applies.
358+
359+
**Recovery after timeout:**
360+
- **Stateless mode:** No recovery needed. The killed process is gone;
361+
the next `exec_run` starts a fresh process in the same container.
362+
- **Stateful mode:** The timed-out block is NOT appended to history
363+
(append-after-success invariant). If the container was restarted
364+
as fallback, the executor must detect this and replay the
365+
accumulated history on the next call.
355366

356367
#### 4.2.4 `GkeCodeExecutor` — Already Implemented
357368

@@ -378,11 +389,19 @@ timeout, so the 300s default applies as before).
378389
#### 4.2.5 Remote Executors (Vertex AI, Agent Engine)
379390

380391
These executors delegate to Google Cloud APIs that have their own internal
381-
timeouts. Adding client-side timeout is still valuable as a safety net:
392+
timeouts. Adding client-side timeout is still valuable as a safety net.
393+
394+
Note: The current `execute_code()` implementations in
395+
`VertexAiCodeExecutor` and `AgentEngineSandboxCodeExecutor` are
396+
**synchronous** (they call blocking SDK methods), so `asyncio.wait_for()`
397+
is not applicable. Use the same thread+join pattern as
398+
`UnsafeLocalCodeExecutor`:
382399

383-
- Wrap the API call in `asyncio.wait_for()` or `threading.Timer`
400+
- Run the blocking API call in a daemon thread with `join(timeout)`
384401
- Return `CodeExecutionResult(stderr='...')` on timeout
385402
- Log a warning that the server-side execution may still be running
403+
- If these executors are migrated to async in the future, switch to
404+
`asyncio.wait_for()` at that point
386405

387406
### 4.3 Migration Plan
388407

@@ -497,7 +516,7 @@ Cons: Not all Python objects are serializable (e.g., open file handles,
497516
database connections, generators). Adds `dill` dependency to the container
498517
image. Serialization/deserialization overhead grows with state size.
499518

500-
**Option C: Shared Globals File (Simple) — RECOMMENDED**
519+
**Option C: Shared Globals File (Simple)**
501520

502521
Write executed code to a cumulative Python file. Each call appends the new
503522
code block and re-executes the entire history:
@@ -553,36 +572,29 @@ of the cumulative replay approach (Option C). Users must keep
553572
side-effecting code in the final block or use Option A (persistent
554573
process) when side effects are unavoidable.
555574

556-
#### 5.2.2 Recommended Approach
575+
#### 5.2.2 Recommended Approach: Option A (Persistent Process)
557576

558-
**Option A (Persistent Process)** is the most robust for true statefulness
559-
and is the standard approach used by Jupyter kernels and similar systems.
560-
Despite its complexity, it provides the best user experience:
577+
**Option A is the recommended approach.** It is the most robust for
578+
true statefulness and is the standard approach used by Jupyter kernels
579+
and similar systems:
561580

562581
- Variables, imports, and objects persist naturally
563582
- No re-execution of side effects
564583
- No serialization issues
565584
- O(1) cost per call (not O(n) like Option C)
566585

567-
However, implementing a full REPL protocol is a significant engineering
568-
effort.
569-
570-
**Given the severity of the side-effect replay problem (Finding 2), we
571-
recommend evaluating whether to skip Phase 1 and go directly to
572-
Option A.** The persistent-process approach eliminates an entire class
573-
of bugs. If the I/O boundary protocol (sentinel markers for output
574-
delimiting) can be solved with reasonable complexity, Phase 1 may not
575-
be worth the technical debt.
576-
577-
**If Phase 1 is pursued as an MVP**, it should be clearly documented as
578-
limited to **pure computation** (variable setup, data transforms,
579-
aggregations) and explicitly unsupported for side-effecting code.
580-
581-
**Phase 1 (MVP, optional):** Option C (cumulative file) with stdout
582-
suppression. Restricted to pure-computation use cases only.
586+
Option C (cumulative replay) has a fundamental side-effect replay
587+
problem that cannot be fully mitigated. We recommend **going directly
588+
to Option A** rather than shipping Option C as an interim MVP that
589+
would accumulate technical debt and user-facing bugs.
583590

584-
**Phase 2 (Full, recommended):** Option A (persistent process) with a
585-
proper execution protocol using sentinel markers for output boundaries.
591+
If a simpler interim is needed before the persistent-process protocol
592+
is ready, Option C may be used with the following restrictions:
593+
- Documented as limited to **pure computation only** (variable setup,
594+
data transforms, aggregations)
595+
- Side-effecting code (file writes, network calls, DB mutations) is
596+
explicitly unsupported and will produce incorrect results
597+
- Clearly labeled as experimental / unstable
586598

587599
#### 5.2.3 Implementation Plan (Phase 1, if pursued)
588600

@@ -874,16 +886,24 @@ class LocalSandboxCodeExecutor(BaseCodeExecutor):
874886
)
875887
spawn_kwargs['preexec_fn'] = _set_limits
876888

877-
cmd = [
878-
'python3', '-c',
879-
f'import resource; '
880-
f'resource.setrlimit(resource.RLIMIT_CPU, '
889+
# Guard resource import for platforms where
890+
# it is unavailable (falls back to timeout-only).
891+
limit_code = (
892+
f'try:\n'
893+
f' import resource\n'
894+
f' resource.setrlimit(resource.RLIMIT_CPU, '
881895
f'({self.max_cpu_seconds}, '
882-
f'{self.max_cpu_seconds})); '
883-
f'resource.setrlimit(resource.RLIMIT_AS, '
896+
f'{self.max_cpu_seconds}))\n'
897+
f' resource.setrlimit(resource.RLIMIT_AS, '
884898
f'({self.max_memory_mb * 1024 * 1024}, '
885-
f'{self.max_memory_mb * 1024 * 1024})); '
886-
f'exec(open({f.name!r}).read())',
899+
f'{self.max_memory_mb * 1024 * 1024}))\n'
900+
f'except (ImportError, OSError):\n'
901+
f' pass\n'
902+
)
903+
cmd = [
904+
'python3', '-c',
905+
limit_code
906+
+ f'exec(open({f.name!r}).read())',
887907
]
888908
result = subprocess.run(
889909
cmd,
@@ -903,13 +923,17 @@ class LocalSandboxCodeExecutor(BaseCodeExecutor):
903923
```
904924

905925
**Platform considerations:**
906-
- `resource.setrlimit` is Unix-only (Linux, macOS)
926+
- `resource.setrlimit` is Unix-only (Linux, macOS). On platforms
927+
where `resource` is unavailable, the inline `-c` wrapper must
928+
skip the `setrlimit` calls and rely on timeout-only enforcement.
929+
The code should guard with `try: import resource; ... except
930+
ImportError: pass` in the wrapper script.
907931
- `process_group=0` requires Python 3.11+ (ADK supports >=3.10, so
908932
this must be gated with a version check or use `preexec_fn` as
909933
fallback on 3.10)
910-
- On Windows, use `subprocess.CREATE_NO_WINDOW` and
911-
`subprocess.Popen` with `creationflags` for job object limits
912-
- Fallback to timeout-only on platforms without `resource` module
934+
- Windows is out of scope (see §2 Non-Goals). The executor should
935+
raise `NotImplementedError` on Windows with a message directing
936+
users to `ContainerCodeExecutor`.
913937

914938
**Why `process_group` instead of `preexec_fn`:**
915939
- `preexec_fn` is not fork-safe with threads — the Python docs warn

0 commit comments

Comments
 (0)