@@ -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
270278import threading
271279
272280def 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
380391These 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,
497516database connections, generators). Adds ` dill ` dependency to the container
498517image. Serialization/deserialization overhead grows with state size.
499518
500- ** Option C: Shared Globals File (Simple) — RECOMMENDED **
519+ ** Option C: Shared Globals File (Simple)**
501520
502521Write executed code to a cumulative Python file. Each call appends the new
503522code block and re-executes the entire history:
@@ -553,36 +572,29 @@ of the cumulative replay approach (Option C). Users must keep
553572side-effecting code in the final block or use Option A (persistent
554573process) 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