os.setsid, os.killpg, and os.getpgid do not exist on Windows and raise AttributeError on import or first call. This breaks the terminal tool, code execution sandbox, process registry, and WhatsApp bridge on Windows. Added _IS_WINDOWS platform guard in all four affected files, following the pattern documented in CONTRIBUTING.md. On Windows, preexec_fn is set to None and process termination falls back to proc.terminate() / proc.kill() instead of process group signals. Files changed: - tools/environments/local.py (3 call sites) - tools/process_registry.py (2 call sites) - tools/code_execution_tool.py (3 call sites) - gateway/platforms/whatsapp.py (3 call sites)
81 lines
2.9 KiB
Python
81 lines
2.9 KiB
Python
"""Tests for Windows compatibility of process management code.
|
|
|
|
Verifies that os.setsid and os.killpg are never called unconditionally,
|
|
and that each module uses a platform guard before invoking POSIX-only functions.
|
|
"""
|
|
|
|
import ast
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
# Files that must have Windows-safe process management
|
|
GUARDED_FILES = [
|
|
"tools/environments/local.py",
|
|
"tools/process_registry.py",
|
|
"tools/code_execution_tool.py",
|
|
"gateway/platforms/whatsapp.py",
|
|
]
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
|
|
|
|
def _get_preexec_fn_values(filepath: Path) -> list:
|
|
"""Find all preexec_fn= keyword arguments in Popen calls."""
|
|
source = filepath.read_text(encoding="utf-8")
|
|
tree = ast.parse(source, filename=str(filepath))
|
|
values = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.keyword) and node.arg == "preexec_fn":
|
|
values.append(ast.dump(node.value))
|
|
return values
|
|
|
|
|
|
class TestNoUnconditionalSetsid:
|
|
"""preexec_fn must never be a bare os.setsid reference."""
|
|
|
|
@pytest.mark.parametrize("relpath", GUARDED_FILES)
|
|
def test_preexec_fn_is_guarded(self, relpath):
|
|
filepath = PROJECT_ROOT / relpath
|
|
if not filepath.exists():
|
|
pytest.skip(f"{relpath} not found")
|
|
values = _get_preexec_fn_values(filepath)
|
|
for val in values:
|
|
# A bare os.setsid would be: Attribute(value=Name(id='os'), attr='setsid')
|
|
assert "attr='setsid'" not in val or "IfExp" in val or "None" in val, (
|
|
f"{relpath} has unconditional preexec_fn=os.setsid"
|
|
)
|
|
|
|
|
|
class TestIsWindowsConstant:
|
|
"""Each guarded file must define _IS_WINDOWS."""
|
|
|
|
@pytest.mark.parametrize("relpath", GUARDED_FILES)
|
|
def test_has_is_windows(self, relpath):
|
|
filepath = PROJECT_ROOT / relpath
|
|
if not filepath.exists():
|
|
pytest.skip(f"{relpath} not found")
|
|
source = filepath.read_text(encoding="utf-8")
|
|
assert "_IS_WINDOWS" in source, (
|
|
f"{relpath} missing _IS_WINDOWS platform guard"
|
|
)
|
|
|
|
|
|
class TestKillpgGuarded:
|
|
"""os.killpg must always be behind a platform check."""
|
|
|
|
@pytest.mark.parametrize("relpath", GUARDED_FILES)
|
|
def test_no_unguarded_killpg(self, relpath):
|
|
filepath = PROJECT_ROOT / relpath
|
|
if not filepath.exists():
|
|
pytest.skip(f"{relpath} not found")
|
|
source = filepath.read_text(encoding="utf-8")
|
|
lines = source.splitlines()
|
|
for i, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if "os.killpg" in stripped or "os.getpgid" in stripped:
|
|
# Check that there's an _IS_WINDOWS guard in the surrounding context
|
|
context = "\n".join(lines[max(0, i - 15):i + 1])
|
|
assert "_IS_WINDOWS" in context or "else:" in context, (
|
|
f"{relpath}:{i + 1} has unguarded os.killpg/os.getpgid call"
|
|
)
|