Skip to content

fix(tools): bound script/command tool subprocesses with a timeout - #2369

Open
abhyudayareddy wants to merge 2 commits into
HolmesGPT:masterfrom
abhyudayareddy:fix/2365-subprocess-tool-timeout
Open

fix(tools): bound script/command tool subprocesses with a timeout#2369
abhyudayareddy wants to merge 2 commits into
HolmesGPT:masterfrom
abhyudayareddy:fix/2365-subprocess-tool-timeout

Conversation

@abhyudayareddy

@abhyudayareddy abhyudayareddy commented Aug 9, 2026

Copy link
Copy Markdown

Problem

Tool.__execute_subprocess() (holmes/core/tools.py) is the shared execution path for every command:/script: toolset tool, including the built-in kubernetes/core tools (kubernetes_jq_query, kubernetes_tabular_query, kubernetes_count, all of which shell out to kubectl). It ran the command via subprocess.run() with no timeout=.

If the underlying process stalls — e.g. kubectl hits an apiserver connection that accepts the TCP handshake but never responds — subprocess.run() blocks the calling thread forever and the child process is never reaped. This doesn't require a pod restart to snowball: a single long-lived Holmes pod accumulates one permanently-blocked worker (and orphaned kubectl process) per stuck call, and each pins significant CPU.

This is not specific to kubernetes/core__execute_subprocess() is shared by every script:-defined tool across every toolset (e.g. kubevela.yaml too).

Fixes #2365.

Fix

  • __execute_subprocess() now runs the command via subprocess.Popen(..., start_new_session=True) + process.communicate(timeout=TOOL_SUBPROCESS_TIMEOUT_SECONDS) (new env var, default 300s) instead of an unbounded subprocess.run().
  • On timeout, the entire process group is killed with os.killpg(..., SIGKILL), not just the immediate bash child. This matters because the direct child of Popen is bash <script>, and the actual offending command (kubectl, etc.) runs as its child — killing only the direct child (what plain subprocess.run(timeout=...) + .kill() does) leaves that grandchild running independently, which is exactly the failure mode described in the issue. start_new_session=True puts the whole tree in its own process group so killpg reaps it all.
  • The race where the process exits on its own right as we're about to signal it (os.killpg raising ProcessLookupError) is handled explicitly and does not propagate.
  • On timeout the tool returns exit code 124 (matching GNU timeout) plus any output produced before the deadline, so the LLM sees a clear, actionable error instead of the call silently vanishing.
  • Added TOOL_SUBPROCESS_TIMEOUT_SECONDS (default 300) to holmes/common/env_vars.py, following the existing TOOL_MEMORY_LIMIT_MB pattern, and documented it in docs/reference/environment-variables.md.

Changes

  • holmes/common/env_vars.py: new TOOL_SUBPROCESS_TIMEOUT_SECONDS env var (default 300s).
  • holmes/core/tools.py: Tool.__execute_subprocess() rewritten around Popen + communicate(timeout=...); new Tool.__kill_process_group_and_collect_output() helper that kills the process group and returns whatever output was buffered.
  • docs/reference/environment-variables.md: reference entry for the new env var.
  • tests/core/test_tool_subprocess_timeout.py: new tests (see below).

Test plan

Added tests/core/test_tool_subprocess_timeout.py, covering:

  • Fast commands still complete normally and return exit code 0.
  • A command exceeding the timeout is killed and returns exit code 124 without blocking anywhere near the original duration.
  • Output produced before the timeout is preserved alongside the timeout notice.
  • The specific bug scenario: a command that backgrounds a child process (simulating a stalled kubectl grandchild) — the regression test confirms that process is actually killed, not left orphaned.
  • The os.killpg race (process exits right as we try to signal it) is swallowed rather than raised.

Validation performed in this environment:

  • ast.parse on all changed Python files (syntax-valid).
  • ruff check and ruff format --diff with the pinned versions from .pre-commit-config.yaml (ruff==0.7.2) — clean on all lines touched by this change.
  • isort --profile black --check-only --diff (isort==6.1.0, closest available to the pinned 7.0.0 for this environment's Python) — the new import is correctly positioned; no changes requested to lines touched by this PR.
  • mypy against holmes/core/tools.py / holmes/common/env_vars.py — no new errors introduced by this change (pre-existing unrelated errors elsewhere in the file/codebase are untouched by this diff).
  • This sandbox only has Python 3.9 available and the project requires 3.10+ (litellm/aiohttp enforce this at install time), so I could not run pytest/make test-without-llm directly here. To compensate, I extracted the exact Popen/communicate/killpg logic added in this PR into a standalone script (no holmes imports) and exercised it directly — confirmed: fast commands return normally, slow commands are killed at the timeout without blocking, partial output is preserved, and critically, the backgrounded "grandchild" process is killed with the new process-group approach while a side-by-side run of the old subprocess.run(timeout=...) pattern left that same grandchild process alive, reproducing the exact bug from the issue. CI will run the full suite including the new test file on a proper Python 3.10+ environment.

Example

export TOOL_SUBPROCESS_TIMEOUT_SECONDS=600  # default is 300

Summary by CodeRabbit

  • New Features

    • Added configurable timeouts for subprocess-based commands and scripts, defaulting to 300 seconds.
    • Timed-out processes now stop cleanly, including child processes, and return exit code 124.
    • Partial output is preserved and accompanied by a timeout notice.
  • Documentation

    • Added configuration guidance, default behavior, timeout handling, and examples for the new setting.
  • Bug Fixes

    • Improved handling of subprocess termination races and timeout results.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@linux-foundation-easycla

linux-foundation-easycla Bot commented Aug 9, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d977ad3-294e-4067-a30d-2e216701f2ad

📥 Commits

Reviewing files that changed from the base of the PR and between 97a99d4 and b936a64.

📒 Files selected for processing (1)
  • docs/reference/environment-variables.md

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


Walkthrough

YAML tool subprocesses now use a configurable 300-second timeout. Timed-out process groups are terminated, partial output is preserved, and the result uses exit code 124. Tests cover normal execution, descendant cleanup, race handling, and structured errors.

Changes

YAML tool subprocess timeout

Layer / File(s) Summary
Timeout configuration and contract
holmes/common/env_vars.py, docs/reference/environment-variables.md
Adds TOOL_SUBPROCESS_TIMEOUT_SECONDS, with a 300-second default and documentation for timeout behavior and configuration.
Process-group timeout execution
holmes/core/tools.py
Runs commands in new process sessions, terminates the full process group on timeout, preserves collected output, and returns exit code 124.
Timeout behavior validation
tests/core/test_tool_subprocess_timeout.py
Tests default configuration, successful commands, timeout output, grandchild termination, process-group race handling, and structured error results.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to b936a

The change bounds command and script execution, terminates timed-out process groups, and preserves a clear timeout result without introducing a concrete merge-blocking issue; it is merge-ready after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant YAMLTool
  participant ProcessGroup
  participant TimeoutCleanup
  YAMLTool->>ProcessGroup: Start command in a new session
  ProcessGroup-->>YAMLTool: Return output and exit code
  YAMLTool->>TimeoutCleanup: Handle timeout
  TimeoutCleanup->>ProcessGroup: Send SIGKILL to process group
  TimeoutCleanup-->>YAMLTool: Return output and exit code 124
Loading

Suggested reviewers: moshemorad

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a timeout to script and command tool subprocesses.
Linked Issues check ✅ Passed The changes satisfy issue #2365. They add a configurable timeout, preserve partial output, return exit code 124, and terminate the complete subprocess process group to prevent stuck commands and orpha…
Out of Scope Changes check ✅ Passed All changes are within scope for issue #2365. The implementation, environment-variable documentation, and regression tests directly support subprocess timeout and process-group cleanup.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Full details: Linked Issues check

Explanation

The changes satisfy issue #2365. They add a configurable timeout, preserve partial output, return exit code 124, and terminate the complete subprocess process group to prevent stuck commands and orphaned descendants.

Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0 files. (1 skipped: 1 unsupported.)


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@netlify

netlify Bot commented Aug 9, 2026

Copy link
Copy Markdown

Deploy Preview for holmes-docs ready!

Name Link
🔨 Latest commit b936a64
🔍 Latest deploy log https://app.netlify.com/projects/holmes-docs/deploys/6a90f1d593010100084de66a
😎 Deploy Preview https://deploy-preview-2369--holmes-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/core/test_tool_subprocess_timeout.py`:
- Around line 109-110: Update the os.killpg mock in the race test around
tool._invoke so it terminates the real sleep process group before raising
ProcessLookupError, or replace the subprocess with a fake process object that
does not spawn a child. Preserve the test’s race-condition assertion while
ensuring cleanup completes without leaving the sleep process running.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8da0d099-2fd8-4159-9f66-4e5a8848874a

📥 Commits

Reviewing files that changed from the base of the PR and between 10b772b and 8af2fe7.

📒 Files selected for processing (4)
  • docs/reference/environment-variables.md
  • holmes/common/env_vars.py
  • holmes/core/tools.py
  • tests/core/test_tool_subprocess_timeout.py

Comment thread tests/core/test_tool_subprocess_timeout.py Outdated
abhyudayareddy pushed a commit to abhyudayareddy/holmesgpt that referenced this pull request Aug 9, 2026
The race-condition test mocked os.killpg to unconditionally raise
ProcessLookupError, which meant the real `sleep 30` process it spawns
via _invoke() was never actually killed - it kept running in the
background for the rest of its 30s duration on every test run.

Have the mock perform the real kill first, then raise, so the
process is genuinely reaped while still exercising the code path
that must swallow the race-condition error.

Flagged by CodeRabbit review on HolmesGPT#2369.

Signed-off-by: Abhyuday <abhyudayreddy@gmail.com>
abhyudayareddy added a commit to abhyudayareddy/holmesgpt that referenced this pull request Aug 9, 2026
The race-condition test mocked os.killpg to unconditionally raise
ProcessLookupError, which meant the real `sleep 30` process it spawns
via _invoke() was never actually killed - it kept running in the
background for the rest of its 30s duration on every test run.

Have the mock perform the real kill first, then raise, so the
process is genuinely reaped while still exercising the code path
that must swallow the race-condition error.

Flagged by CodeRabbit review on HolmesGPT#2369.

Signed-off-by: Abhyuday <abhyudayareddy@gmail.com>
@abhyudayareddy
abhyudayareddy force-pushed the fix/2365-subprocess-tool-timeout branch from 97a99d4 to e7bca26 Compare August 9, 2026 14:14
@abhyudayareddy

Copy link
Copy Markdown
Author

cc @aantn — you're the primary author of this file, so thought you'd be the right person to take a look whenever you have time. CI is green aside from a pre-existing License Compliance baseline finding unrelated to this change (this diff adds no new dependencies). Thanks!

@abhyudayareddy

Copy link
Copy Markdown
Author

cc @aantn — checking back in on this one. DCO is green; the tool subprocess timeout fix is still ready for review whenever you have a chance. Thanks!

Tool.__execute_subprocess() ran every command:/script: toolset tool
(e.g. kubectl-based kubernetes/core tools) via subprocess.run() with no
timeout. A stalled child process - for example an apiserver connection
that accepts the TCP handshake but never responds - blocked the calling
thread and leaked the process forever, with no way to recover short of
restarting the pod.

Switch to subprocess.Popen(start_new_session=True) + communicate(timeout=
TOOL_SUBPROCESS_TIMEOUT_SECONDS) (default 300s, configurable). On timeout,
kill the whole process group with os.killpg() instead of just the direct
shell child, so a stalled grandchild (e.g. kubectl) spawned by the script
is also reaped. The tool returns exit code 124, matching GNU timeout,
with any output produced before the deadline.

Fixes HolmesGPT#2365

Signed-off-by: Abhyuday <abhyudayareddy@gmail.com>
The race-condition test mocked os.killpg to unconditionally raise
ProcessLookupError, which meant the real `sleep 30` process it spawns
via _invoke() was never actually killed - it kept running in the
background for the rest of its 30s duration on every test run.

Have the mock perform the real kill first, then raise, so the
process is genuinely reaped while still exercising the code path
that must swallow the race-condition error.

Flagged by CodeRabbit review on HolmesGPT#2369.

Signed-off-by: Abhyuday <abhyudayareddy@gmail.com>
@abhyudayareddy
abhyudayareddy force-pushed the fix/2365-subprocess-tool-timeout branch from e7bca26 to b936a64 Compare August 28, 2026 02:26
@abhyudayareddy

Copy link
Copy Markdown
Author

Rebased onto latest master (was ~29 commits behind) — clean, no conflicts. pytest tests/core/test_tool_subprocess_timeout.py → 7 passed.

The CodeRabbit "kill the process group before raising" note was already handled in b936a64 (the killpg_then_raise helper calls the real os.killpg first), and that thread is resolved. Still ready for review whenever you have a chance, @aantn.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Script-based toolset tools (kubernetes/core, etc.) can hang forever — subprocess.run() has no timeout

1 participant