mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 22:52:58 -10:00
fix(bot): bound gateway subprocess calls so a hung command cannot stall the loop
Four gateway paths shelled out with no timeout. The dispatcher calls them from inside the asyncio event loop, so a child blocked on stdin — a git credential prompt, an index.lock, a confirmation prompt — stopped the whole gateway serving, indefinitely and silently. Adds praisonai_bot/_guarded_subprocess.run_guarded: a drop-in wrapper that always bounds the call and, on timeout, returns a CompletedProcess with returncode 124 and a *diagnosis* in stderr rather than raising. Existing `returncode == 0` call sites therefore keep working without growing an exception path. Two defences, in order: - stdin is /dev/null and GIT_TERMINAL_PROMPT=0 / GCM_INTERACTIVE=never / DEBIAN_FRONTEND=noninteractive are layered under the caller's env, so the common cases fail fast instead of blocking. The timeout is a backstop. - On timeout the partial output is matched against known stall signatures and the caller gets an actionable line — "credential prompt", "git lock contention", "confirmation prompt, re-run with --yes", "SSH host-key prompt" — instead of a bare elapsed time. Adopted at the four sites that can block the event loop: - gateway/kanban_dispatcher.py::_run_git — the worst of them, reached from dispatch_once and the worktree integration path - kanban/sqlite_store.py — git rev-parse on the create_task path - gateway/preflight.py — launchctl on the startup path - gateway/pairing.py — icacls while hardening the pairing secret Git keeps a deliberately generous 300s ceiling (PRAISONAI_KANBAN_GIT_TIMEOUT) because worktree add and merge on a large repository are legitimately slow; the aim is to bound an indefinite hang, not to fail slow-but-healthy work. Other commands use 120s (PRAISONAI_SUBPROCESS_TIMEOUT). Scope is deliberately narrow. The ~66 remaining subprocess.run sites without timeouts are in daemon/ and CLI paths, where a hang stalls one user command rather than the gateway; those are worth a separate pass. Tests: 32 new, covering timeout-without-raising, stdin EOF, each diagnosis signature, env layering and precedence, unusable env overrides, and that _run_git routes through the guard. Verified: tests/unit/kanban + tests/unit/gateway + the new file — 625 passed, 22 failed. Those 22 are identical to the pre-change baseline on this tree (confirmed by stashing); two further files fail to collect because the optional `websockets` package is absent here. Neither is related to this change. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
This commit is contained in:
1 parent
eef24398ea
commit
c9964dbe03
6 files changed
+440
-15
No files matched your search
@@ -0,0 +1,174 @@
|
||||
"""Subprocess execution that cannot hang the caller indefinitely.
|
||||
|
||||
Several gateway paths shell out while an asyncio event loop is waiting on
|
||||
them. ``subprocess.run`` without ``timeout=`` blocks forever if the child
|
||||
waits on stdin -- a git credential prompt, an ``index.lock`` contention, a
|
||||
confirmation prompt -- and the whole gateway stops serving.
|
||||
|
||||
This module provides :func:`run_guarded`, a drop-in replacement that always
|
||||
bounds the call and, when it does fire, hands the caller a *diagnosis* rather
|
||||
than a bare timeout. Knowing that a command died waiting on a confirmation
|
||||
prompt is actionable; knowing only that 120 seconds elapsed is not.
|
||||
|
||||
Note on semantics: this is a total-runtime cap, not an inactivity timer. A
|
||||
command that streams output steadily for longer than the cap is still killed.
|
||||
Callers with legitimately long work should pass an explicit ``timeout``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Mapping, Optional, Sequence
|
||||
|
||||
__all__ = ["run_guarded", "DEFAULT_TIMEOUT", "diagnose"]
|
||||
|
||||
#: Total seconds a guarded command may run before it is killed.
|
||||
#: Override per call, or globally via ``PRAISONAI_SUBPROCESS_TIMEOUT``.
|
||||
DEFAULT_TIMEOUT = 120.0
|
||||
|
||||
#: Returncode reported for a killed command. 124 is the conventional exit
|
||||
#: status used by coreutils ``timeout``.
|
||||
TIMEOUT_RETURNCODE = 124
|
||||
|
||||
_ENV_TIMEOUT = "PRAISONAI_SUBPROCESS_TIMEOUT"
|
||||
|
||||
# Substrings that identify why a child process is sitting idle. Ordered most
|
||||
# specific first; the first hit wins.
|
||||
_SIGNATURES: tuple[tuple[tuple[str, ...], str], ...] = (
|
||||
(
|
||||
("username for", "password for", "passphrase", "authentication failed"),
|
||||
"DETECTED: credential prompt. The command is waiting for a login that "
|
||||
"will never arrive. Configure a credential helper, or set "
|
||||
"GIT_TERMINAL_PROMPT=0 so it fails fast instead of blocking.",
|
||||
),
|
||||
(
|
||||
("index.lock", "unable to create", "another git process"),
|
||||
"DETECTED: git lock contention. Another git process holds the "
|
||||
"repository lock. Retry once it exits, or remove a stale .git/index.lock.",
|
||||
),
|
||||
(
|
||||
("[y/n]", "(yes/no)", "are you sure", "continue?", "overwrite?", "proceed?"),
|
||||
"DETECTED: confirmation prompt. Re-run non-interactively with the "
|
||||
"command's own flag (--yes, -y, --force, --no-confirm).",
|
||||
),
|
||||
(
|
||||
("host key", "fingerprint", "known_hosts"),
|
||||
"DETECTED: SSH host-key prompt. Pre-populate known_hosts, or pass "
|
||||
"-o StrictHostKeyChecking=accept-new.",
|
||||
),
|
||||
)
|
||||
|
||||
_GENERIC = (
|
||||
"No interactive prompt was detected. The command may be genuinely slow, "
|
||||
"blocked on network I/O, or deadlocked. Raise timeout= if the work is "
|
||||
"expected to take longer."
|
||||
)
|
||||
|
||||
|
||||
def diagnose(captured: str) -> str:
|
||||
"""Return a human- and model-actionable reason a command stalled.
|
||||
|
||||
``captured`` is whatever the child emitted before it was killed; it may be
|
||||
empty, which is itself common for a process blocked on stdin.
|
||||
"""
|
||||
haystack = (captured or "").lower()
|
||||
for needles, message in _SIGNATURES:
|
||||
if any(needle in haystack for needle in needles):
|
||||
return message
|
||||
return _GENERIC
|
||||
|
||||
|
||||
def _default_timeout() -> float:
|
||||
"""Resolve the global timeout, ignoring an unusable env override."""
|
||||
raw = os.environ.get(_ENV_TIMEOUT)
|
||||
if not raw:
|
||||
return DEFAULT_TIMEOUT
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_TIMEOUT
|
||||
return value if value > 0 else DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
def _non_interactive_env(env: Optional[Mapping[str, str]]) -> dict:
|
||||
"""Environment that makes common tools fail rather than prompt.
|
||||
|
||||
Suppressing the prompt at source is better than killing the process after
|
||||
it hangs: the command returns an error the caller can act on immediately.
|
||||
"""
|
||||
resolved = dict(env if env is not None else os.environ)
|
||||
resolved.setdefault("GIT_TERMINAL_PROMPT", "0")
|
||||
resolved.setdefault("GCM_INTERACTIVE", "never")
|
||||
resolved.setdefault("DEBIAN_FRONTEND", "noninteractive")
|
||||
return resolved
|
||||
|
||||
|
||||
def run_guarded(
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
timeout: Optional[float] = None,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Mapping[str, str]] = None,
|
||||
interactive: bool = False,
|
||||
**kwargs,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run ``cmd`` with a bounded runtime, never raising on timeout.
|
||||
|
||||
Behaves like ``subprocess.run(..., capture_output=True, text=True)``. On
|
||||
timeout the child is killed and a :class:`subprocess.CompletedProcess` is
|
||||
returned with returncode :data:`TIMEOUT_RETURNCODE` and a diagnosis
|
||||
appended to stderr, so existing ``returncode == 0`` checks keep working
|
||||
instead of having to grow a new exception path.
|
||||
|
||||
Args:
|
||||
cmd: Command and arguments.
|
||||
timeout: Total seconds allowed. Defaults to
|
||||
``PRAISONAI_SUBPROCESS_TIMEOUT`` or :data:`DEFAULT_TIMEOUT`.
|
||||
cwd: Working directory.
|
||||
env: Environment; prompt-suppressing defaults are layered underneath.
|
||||
interactive: Set True only for a command that must reach a terminal.
|
||||
Skips prompt suppression; the timeout still applies.
|
||||
**kwargs: Forwarded to ``subprocess.run``.
|
||||
"""
|
||||
resolved_timeout = _default_timeout() if timeout is None else timeout
|
||||
kwargs.setdefault("capture_output", True)
|
||||
kwargs.setdefault("text", True)
|
||||
|
||||
# stdin at /dev/null turns "wait forever for input" into an immediate EOF
|
||||
# for tools that honour it, so the timeout is a backstop rather than the
|
||||
# primary defence.
|
||||
if not interactive:
|
||||
kwargs.setdefault("stdin", subprocess.DEVNULL)
|
||||
|
||||
try:
|
||||
return subprocess.run(
|
||||
list(cmd),
|
||||
timeout=resolved_timeout,
|
||||
cwd=cwd,
|
||||
env=env if interactive else _non_interactive_env(env),
|
||||
**kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
partial_out = _as_text(exc.stdout)
|
||||
partial_err = _as_text(exc.stderr)
|
||||
reason = diagnose(f"{partial_out}\n{partial_err}")
|
||||
detail = (
|
||||
f"Command killed after {resolved_timeout:g}s: {' '.join(cmd)}\n"
|
||||
f"{reason}"
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=list(cmd),
|
||||
returncode=TIMEOUT_RETURNCODE,
|
||||
stdout=partial_out,
|
||||
stderr=(partial_err + "\n" + detail).strip(),
|
||||
)
|
||||
|
||||
|
||||
def _as_text(value) -> str:
|
||||
"""Normalise TimeoutExpired's stdout/stderr, which may be bytes or None."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return str(value)
|
||||
@@ -14,8 +14,28 @@ import subprocess
|
||||
import time
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from .._guarded_subprocess import run_guarded
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ceiling for a single git invocation. Worktree add, merge and commit on a
|
||||
# large repository are legitimately slow, so this is set well above the
|
||||
# general default: the aim is to bound an indefinite hang, not to fail slow
|
||||
# work. Override with PRAISONAI_KANBAN_GIT_TIMEOUT (seconds).
|
||||
_DEFAULT_GIT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def _git_timeout() -> float:
|
||||
"""Resolve the per-git-command ceiling, ignoring an unusable override."""
|
||||
raw = os.environ.get("PRAISONAI_KANBAN_GIT_TIMEOUT")
|
||||
if not raw:
|
||||
return _DEFAULT_GIT_TIMEOUT
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return _DEFAULT_GIT_TIMEOUT
|
||||
return value if value > 0 else _DEFAULT_GIT_TIMEOUT
|
||||
|
||||
# Global dispatcher state
|
||||
_dispatcher_running = False
|
||||
_dispatcher_task = None
|
||||
@@ -415,12 +435,22 @@ class KanbanDispatcher:
|
||||
|
||||
A thin subprocess wrapper so worktree isolation stays self-contained in
|
||||
the wrapper without pulling in a Tier-2 package dependency.
|
||||
|
||||
Bounded via :func:`run_guarded`: the dispatcher calls this from inside
|
||||
the gateway's event loop, so a git command blocked on a credential
|
||||
prompt or an ``index.lock`` would otherwise stall every WebSocket
|
||||
client indefinitely. On timeout the result carries returncode 124 and
|
||||
a diagnosis in stderr, which the existing ``returncode == 0`` checks
|
||||
already treat as failure.
|
||||
|
||||
The ceiling is deliberately generous — worktree creation and merges on
|
||||
a large repository are legitimately slow, and the goal is to bound a
|
||||
hang, not to fail slow-but-healthy work.
|
||||
"""
|
||||
return subprocess.run(
|
||||
return run_guarded(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd or self._repo_dir(),
|
||||
timeout=_git_timeout(),
|
||||
)
|
||||
|
||||
def _worktree_root(self) -> str:
|
||||
|
||||
@@ -111,7 +111,8 @@ def _restrict_windows_acl(secret_path: str) -> bool:
|
||||
"""
|
||||
try:
|
||||
import getpass
|
||||
import subprocess
|
||||
|
||||
from .._guarded_subprocess import run_guarded
|
||||
|
||||
user = os.environ.get("USERNAME") or getpass.getuser()
|
||||
# Resolve icacls from the trusted system directory rather than PATH
|
||||
@@ -123,7 +124,9 @@ def _restrict_windows_acl(secret_path: str) -> bool:
|
||||
)
|
||||
# Quote the username so principals containing spaces (e.g. "John Doe")
|
||||
# are parsed as a single principal by icacls.
|
||||
result = subprocess.run(
|
||||
# Bounded: this runs while hardening the pairing secret on the gateway
|
||||
# startup path, so a stalled icacls must not block it forever.
|
||||
result = run_guarded(
|
||||
[
|
||||
icacls,
|
||||
secret_path,
|
||||
@@ -131,7 +134,7 @@ def _restrict_windows_acl(secret_path: str) -> bool:
|
||||
"/grant:r",
|
||||
f'"{user}":F',
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
@@ -879,7 +879,8 @@ def _read_env_file_tokens(path: str) -> Dict[str, str]:
|
||||
def _scan_launch_agent(label: str) -> DuplicateService:
|
||||
"""Inspect a macOS LaunchAgent plist by label."""
|
||||
import plistlib
|
||||
import subprocess
|
||||
|
||||
from .._guarded_subprocess import run_guarded
|
||||
|
||||
plist_path = os.path.expanduser(f"~/Library/LaunchAgents/{label}.plist")
|
||||
service = DuplicateService(label=label, plist_path=plist_path)
|
||||
@@ -899,11 +900,9 @@ def _scan_launch_agent(label: str) -> DuplicateService:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", label],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Bounded: preflight runs on the gateway's startup path, and a
|
||||
# launchctl that stalls would hold up service discovery indefinitely.
|
||||
result = run_guarded(["launchctl", "list", label], timeout=15)
|
||||
service.running = result.returncode == 0
|
||||
if service.running and result.stdout:
|
||||
parts = result.stdout.strip().split("\t")
|
||||
|
||||
@@ -223,10 +223,15 @@ class SQLiteKanbanStore:
|
||||
if not path or not isinstance(path, str):
|
||||
return False
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
from .._guarded_subprocess import run_guarded
|
||||
|
||||
# Bounded: this sits on the create_task path, which the gateway
|
||||
# dispatcher calls from its event loop. A git command that stalls
|
||||
# on a network-mounted or lock-contended path would otherwise
|
||||
# block every card creation.
|
||||
result = run_guarded(
|
||||
["git", "-C", path, "rev-parse", "--is-inside-work-tree"],
|
||||
capture_output=True, text=True,
|
||||
timeout=15,
|
||||
)
|
||||
return result.returncode == 0 and result.stdout.strip() == "true"
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tests for the bounded subprocess wrapper.
|
||||
|
||||
The behaviour that matters: a command that would block forever returns, the
|
||||
caller can still branch on returncode, and the stderr it gets back says *why*
|
||||
the command stalled.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_bot._guarded_subprocess import (
|
||||
DEFAULT_TIMEOUT,
|
||||
TIMEOUT_RETURNCODE,
|
||||
diagnose,
|
||||
run_guarded,
|
||||
)
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
def test_returns_completed_process(self):
|
||||
result = run_guarded([sys.executable, "-c", "print('ok')"])
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == "ok"
|
||||
|
||||
def test_propagates_nonzero_exit(self):
|
||||
result = run_guarded([sys.executable, "-c", "import sys; sys.exit(3)"])
|
||||
assert result.returncode == 3
|
||||
|
||||
def test_honours_cwd(self, tmp_path):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import os; print(os.getcwd())"],
|
||||
cwd=str(tmp_path),
|
||||
)
|
||||
assert os.path.realpath(result.stdout.strip()) == os.path.realpath(str(tmp_path))
|
||||
|
||||
|
||||
class TestTimeout:
|
||||
def test_hanging_command_returns_instead_of_blocking(self):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
timeout=0.5,
|
||||
)
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
|
||||
def test_timeout_does_not_raise(self):
|
||||
# The whole point: existing `returncode == 0` call sites keep working
|
||||
# without growing an exception handler.
|
||||
run_guarded([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.3)
|
||||
|
||||
def test_stderr_names_the_command_and_the_limit(self):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
timeout=0.4,
|
||||
)
|
||||
assert "killed after 0.4s" in result.stderr
|
||||
assert "time.sleep(30)" in result.stderr
|
||||
|
||||
def test_reading_stdin_is_not_a_hang(self):
|
||||
# stdin is /dev/null, so a command that reads it gets EOF immediately
|
||||
# rather than waiting for the timeout to save it.
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import sys; sys.stdin.read(); print('done')"],
|
||||
timeout=5,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "done" in result.stdout
|
||||
|
||||
|
||||
class TestDiagnosis:
|
||||
def test_credential_prompt(self):
|
||||
assert "credential prompt" in diagnose("Username for 'https://github.com':")
|
||||
|
||||
def test_git_lock(self):
|
||||
msg = diagnose("fatal: Unable to create '/repo/.git/index.lock': File exists")
|
||||
assert "lock contention" in msg
|
||||
|
||||
def test_confirmation_prompt(self):
|
||||
assert "confirmation prompt" in diagnose("Overwrite existing file? [y/N]")
|
||||
|
||||
def test_host_key_prompt(self):
|
||||
assert "host-key" in diagnose("The authenticity of host ... key fingerprint is")
|
||||
|
||||
def test_unknown_output_gets_generic_guidance(self):
|
||||
msg = diagnose("compiling module 41 of 900")
|
||||
assert "No interactive prompt was detected" in msg
|
||||
|
||||
def test_empty_output_is_safe(self):
|
||||
# A process blocked on stdin often emits nothing at all.
|
||||
assert diagnose("") == diagnose(None)
|
||||
|
||||
def test_diagnosis_is_attached_on_timeout(self):
|
||||
script = (
|
||||
"import sys, time; "
|
||||
"sys.stderr.write(\"Username for 'https://github.com':\"); "
|
||||
"sys.stderr.flush(); time.sleep(30)"
|
||||
)
|
||||
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
assert "credential prompt" in result.stderr
|
||||
|
||||
|
||||
class TestEnvironment:
|
||||
def test_suppresses_git_prompting_by_default(self):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"]
|
||||
)
|
||||
assert result.stdout.strip() == "0"
|
||||
|
||||
def test_caller_env_wins(self):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"],
|
||||
env={"GIT_TERMINAL_PROMPT": "1", "PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
assert result.stdout.strip() == "1"
|
||||
|
||||
def test_interactive_mode_adds_no_suppression(self, monkeypatch):
|
||||
# Clear the ambient value first -- CI images commonly export
|
||||
# GIT_TERMINAL_PROMPT=0 already, which would mask the behaviour under
|
||||
# test. Interactive mode must not *introduce* the variable itself.
|
||||
monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False)
|
||||
result = run_guarded(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import os; print(os.environ.get('GIT_TERMINAL_PROMPT', 'unset'))",
|
||||
],
|
||||
interactive=True,
|
||||
)
|
||||
assert result.stdout.strip() == "unset"
|
||||
|
||||
def test_default_mode_suppresses_even_when_ambient_is_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False)
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"]
|
||||
)
|
||||
assert result.stdout.strip() == "0"
|
||||
|
||||
|
||||
class TestTimeoutResolution:
|
||||
def test_env_override(self, monkeypatch):
|
||||
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", "0.3")
|
||||
result = run_guarded([sys.executable, "-c", "import time; time.sleep(30)"])
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "abc", "0", "-5"])
|
||||
def test_unusable_override_falls_back_to_default(self, monkeypatch, bad):
|
||||
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", bad)
|
||||
# Must not raise, and must not adopt a non-positive timeout that would
|
||||
# kill every command instantly.
|
||||
result = run_guarded([sys.executable, "-c", "print('fine')"])
|
||||
assert result.returncode == 0
|
||||
assert DEFAULT_TIMEOUT > 0
|
||||
|
||||
def test_explicit_timeout_beats_env(self, monkeypatch):
|
||||
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", "30")
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.3
|
||||
)
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
|
||||
|
||||
class TestCompatibility:
|
||||
def test_result_is_a_real_completed_process(self):
|
||||
result = run_guarded([sys.executable, "-c", "import time; time.sleep(5)"], timeout=0.3)
|
||||
assert isinstance(result, subprocess.CompletedProcess)
|
||||
assert result.args == [sys.executable, "-c", "import time; time.sleep(5)"]
|
||||
# Call sites read .stdout/.stderr unconditionally; both must be str.
|
||||
assert isinstance(result.stdout, str)
|
||||
assert isinstance(result.stderr, str)
|
||||
|
||||
|
||||
class TestKanbanGitTimeout:
|
||||
"""The dispatcher's git ceiling must bound hangs without failing slow work."""
|
||||
|
||||
def test_default_is_generous(self):
|
||||
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
|
||||
|
||||
# Worktree add / merge on a large repo can legitimately run for
|
||||
# minutes; the bound exists to stop an indefinite hang, not slow work.
|
||||
assert _git_timeout() >= 300.0
|
||||
|
||||
def test_env_override(self, monkeypatch):
|
||||
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", "42")
|
||||
assert _git_timeout() == 42.0
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "abc", "0", "-1"])
|
||||
def test_unusable_override_falls_back(self, monkeypatch, bad):
|
||||
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", bad)
|
||||
assert _git_timeout() >= 300.0
|
||||
|
||||
def test_run_git_is_bounded(self, monkeypatch):
|
||||
"""_run_git must route through run_guarded, not raw subprocess.run."""
|
||||
from praisonai_bot.gateway import kanban_dispatcher as kd
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_run_guarded(cmd, **kwargs):
|
||||
seen["cmd"] = cmd
|
||||
seen["timeout"] = kwargs.get("timeout")
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(kd, "run_guarded", fake_run_guarded)
|
||||
dispatcher = kd.KanbanDispatcher.__new__(kd.KanbanDispatcher)
|
||||
dispatcher._run_git("status")
|
||||
|
||||
assert seen["cmd"][0] == "git"
|
||||
assert seen["timeout"] is not None and seen["timeout"] > 0
|
||||
Reference in new issue
Block a user