feat(resources): add EvictionExecutor with SIGTERM/grace/SIGKILL sequence
This commit is contained in:
parent
a79fd10f45
commit
4a857d5339
2 changed files with 122 additions and 0 deletions
79
circuitforge_core/resources/agent/eviction_executor.py
Normal file
79
circuitforge_core/resources/agent/eviction_executor.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import psutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_GRACE_S = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvictionResult:
|
||||
success: bool
|
||||
method: str # "sigterm", "sigkill", "already_gone", "not_found", "error"
|
||||
message: str
|
||||
|
||||
|
||||
class EvictionExecutor:
|
||||
def __init__(self, grace_period_s: float = _DEFAULT_GRACE_S) -> None:
|
||||
self._default_grace = grace_period_s
|
||||
|
||||
def evict_pid(
|
||||
self,
|
||||
pid: int,
|
||||
grace_period_s: float | None = None,
|
||||
) -> EvictionResult:
|
||||
grace = grace_period_s if grace_period_s is not None else self._default_grace
|
||||
|
||||
if not psutil.pid_exists(pid):
|
||||
return EvictionResult(
|
||||
success=False, method="not_found",
|
||||
message=f"PID {pid} not found"
|
||||
)
|
||||
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return EvictionResult(
|
||||
success=True, method="already_gone",
|
||||
message=f"PID {pid} vanished before SIGTERM"
|
||||
)
|
||||
except PermissionError as exc:
|
||||
return EvictionResult(
|
||||
success=False, method="error",
|
||||
message=f"Permission denied terminating PID {pid}: {exc}"
|
||||
)
|
||||
|
||||
# Wait for grace period
|
||||
deadline = time.monotonic() + grace
|
||||
while time.monotonic() < deadline:
|
||||
if not psutil.pid_exists(pid):
|
||||
logger.info("PID %d exited cleanly after SIGTERM", pid)
|
||||
return EvictionResult(
|
||||
success=True, method="sigterm",
|
||||
message=f"PID {pid} exited after SIGTERM"
|
||||
)
|
||||
time.sleep(0.05)
|
||||
|
||||
# Escalate to SIGKILL
|
||||
if psutil.pid_exists(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
logger.warning("PID %d required SIGKILL", pid)
|
||||
return EvictionResult(
|
||||
success=True, method="sigkill",
|
||||
message=f"PID {pid} killed with SIGKILL"
|
||||
)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
return EvictionResult(
|
||||
success=True, method="sigkill",
|
||||
message=f"PID {pid} is gone"
|
||||
)
|
||||
43
tests/test_resources/test_eviction_executor.py
Normal file
43
tests/test_resources/test_eviction_executor.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import signal
|
||||
from unittest.mock import patch, call
|
||||
import pytest
|
||||
from circuitforge_core.resources.agent.eviction_executor import EvictionExecutor, EvictionResult
|
||||
|
||||
|
||||
def test_evict_by_pid_sends_sigterm_then_sigkill():
|
||||
executor = EvictionExecutor(grace_period_s=0.01)
|
||||
# pid_exists always True → grace period expires → SIGKILL fires
|
||||
with patch("os.kill") as mock_kill, \
|
||||
patch("circuitforge_core.resources.agent.eviction_executor.psutil") as mock_psutil:
|
||||
mock_psutil.pid_exists.return_value = True
|
||||
result = executor.evict_pid(pid=1234, grace_period_s=0.01)
|
||||
|
||||
assert result.success is True
|
||||
calls = mock_kill.call_args_list
|
||||
assert call(1234, signal.SIGTERM) in calls
|
||||
assert call(1234, signal.SIGKILL) in calls
|
||||
|
||||
|
||||
def test_evict_pid_succeeds_on_sigterm_alone():
|
||||
executor = EvictionExecutor(grace_period_s=0.1)
|
||||
with patch("os.kill"), \
|
||||
patch("circuitforge_core.resources.agent.eviction_executor.psutil") as mock_psutil:
|
||||
mock_psutil.pid_exists.side_effect = [True, False] # gone after SIGTERM
|
||||
result = executor.evict_pid(pid=5678, grace_period_s=0.01)
|
||||
assert result.success is True
|
||||
assert result.method == "sigterm"
|
||||
|
||||
|
||||
def test_evict_pid_not_found_returns_failure():
|
||||
executor = EvictionExecutor()
|
||||
with patch("circuitforge_core.resources.agent.eviction_executor.psutil") as mock_psutil:
|
||||
mock_psutil.pid_exists.return_value = False
|
||||
result = executor.evict_pid(pid=9999)
|
||||
assert result.success is False
|
||||
assert "not found" in result.message.lower()
|
||||
|
||||
|
||||
def test_eviction_result_is_immutable():
|
||||
result = EvictionResult(success=True, method="sigterm", message="ok")
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
result.success = False # type: ignore
|
||||
Loading…
Reference in a new issue