The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.
Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.
Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
skip-on-Windows moves onto the 11 methods that had no host mark; the
11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
platforms("linux") in favour of the module's "posix" (mkfifo exists on
macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
or wider marks removed.
145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Live Windows probes for the fail-closed taskkill process-identity guard.
|
|
|
|
Runs only on real Windows (the on-demand ``wine2e/**`` windows-latest lane).
|
|
These tests spawn REAL processes and drive the REAL guard code against the
|
|
live process table — the coverage the mocked Linux suites cannot provide.
|
|
|
|
Class under test (#98814 / #89614):
|
|
|
|
- ``gateway.status.terminate_pid(force=True)`` requires a matching
|
|
``expected_start_time`` and must refuse (never taskkill) on a missing or
|
|
mismatched identity.
|
|
- ``hermes_cli._subprocess_compat.pid_is_hermes`` fails closed on foreign
|
|
processes and identity mismatches.
|
|
"""
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.platforms("windows") # live taskkill-identity probes are Windows-only
|
|
|
|
|
|
def _spawn_sleeper(seconds: int = 60) -> subprocess.Popen:
|
|
return subprocess.Popen(
|
|
[sys.executable, "-c", f"import time; time.sleep({seconds})"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
|
|
|
|
def _cleanup(proc: subprocess.Popen) -> None:
|
|
try:
|
|
proc.kill()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class TestTerminatePidIdentityLive:
|
|
def test_matching_identity_kills_real_process(self):
|
|
from gateway.status import get_process_start_time, terminate_pid
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
start = get_process_start_time(proc.pid)
|
|
assert start is not None, "live process must have a fingerprint"
|
|
terminate_pid(proc.pid, force=True, expected_start_time=start)
|
|
assert proc.wait(timeout=15) is not None
|
|
finally:
|
|
_cleanup(proc)
|
|
|
|
def test_missing_expectation_refuses_and_process_survives(self):
|
|
from gateway.status import terminate_pid
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
with pytest.raises(OSError, match="start-time guard"):
|
|
terminate_pid(proc.pid, force=True)
|
|
assert proc.poll() is None, "refusal must leave the process running"
|
|
finally:
|
|
_cleanup(proc)
|
|
|
|
def test_mismatched_identity_refuses_and_process_survives(self):
|
|
"""The recycled-PID scenario: recorded identity != live identity."""
|
|
from gateway.status import get_process_start_time, terminate_pid
|
|
|
|
# Simulate recycling: capture the identity of a process that then
|
|
# dies, and respawn a DIFFERENT process. We can't force Windows to
|
|
# hand back the same PID, so assert the guard refuses when the stale
|
|
# fingerprint is presented against the new (different) live process.
|
|
victim = _spawn_sleeper(1)
|
|
stale_start = get_process_start_time(victim.pid)
|
|
victim.wait(timeout=30)
|
|
|
|
impostor = _spawn_sleeper()
|
|
try:
|
|
live_start = get_process_start_time(impostor.pid)
|
|
assert live_start is not None
|
|
if stale_start == live_start:
|
|
pytest.skip("fingerprints collided; cannot express mismatch")
|
|
with pytest.raises(OSError, match="identity"):
|
|
terminate_pid(
|
|
impostor.pid, force=True, expected_start_time=stale_start
|
|
)
|
|
assert impostor.poll() is None, "mismatch must never kill"
|
|
finally:
|
|
_cleanup(impostor)
|
|
|
|
def test_dead_pid_identity_unavailable_refuses(self):
|
|
from gateway.status import get_process_start_time, terminate_pid
|
|
|
|
proc = _spawn_sleeper(1)
|
|
pid = proc.pid
|
|
start = get_process_start_time(pid)
|
|
proc.wait(timeout=30)
|
|
# Give the OS a beat to drop the process object.
|
|
time.sleep(0.5)
|
|
if get_process_start_time(pid) == start:
|
|
pytest.skip("PID instantly recycled onto identical fingerprint")
|
|
with pytest.raises(OSError):
|
|
terminate_pid(pid, force=True, expected_start_time=start)
|
|
|
|
|
|
class TestPidIsHermesLive:
|
|
def test_foreign_real_process_is_refused(self):
|
|
"""A live non-Hermes process (bare python sleeper in a temp-ish argv)
|
|
must never be judged safe for taskkill."""
|
|
from hermes_cli._subprocess_compat import pid_is_hermes
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
# sys.executable in CI lives under a uv/hostedtoolcache path with
|
|
# no 'hermes' token; if the checkout path itself contains one this
|
|
# assertion is environment-dependent, so guard for it.
|
|
if "hermes" in sys.executable.lower():
|
|
pytest.skip("interpreter path names hermes; probe would match")
|
|
assert pid_is_hermes(proc.pid) is False
|
|
finally:
|
|
_cleanup(proc)
|
|
|
|
def test_stale_fingerprint_is_refused_even_for_hermes_argv(self):
|
|
from gateway.status import get_process_start_time
|
|
from hermes_cli._subprocess_compat import pid_is_hermes
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
live = get_process_start_time(proc.pid)
|
|
assert live is not None
|
|
assert (
|
|
pid_is_hermes(proc.pid, expected_start_time=live + 12345) is False
|
|
)
|
|
finally:
|
|
_cleanup(proc)
|
|
|
|
def test_nonexistent_pid_is_refused(self):
|
|
from hermes_cli._subprocess_compat import pid_is_hermes
|
|
|
|
assert pid_is_hermes(2**24) is False
|