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.
113 lines
4.3 KiB
Python
113 lines
4.3 KiB
Python
"""``bounded_probe_run`` — deadlock-safe capture for fail-open probes (#87134).
|
|
|
|
On Windows, ``subprocess.run(..., capture_output=True, timeout=N)`` can hang
|
|
FOREVER after its timeout fires: run()'s cleanup kills the direct child and
|
|
then joins the pipe reader threads with an unbounded ``communicate()``. A
|
|
descendant (``conhost.exe`` under wmic/powershell, ``git.exe`` under a
|
|
launcher shim) holding duplicated pipe handles keeps the pipes from EOF and
|
|
the join never returns. ``hermes update`` wedged exactly there inside
|
|
``_scan_gateway_pids`` on machines where the full ``Win32_Process`` scan
|
|
exceeds its budget.
|
|
|
|
``bounded_probe_run`` is the shared, generalized form of the fix that
|
|
``bounded_git_probe`` already proved for git probes (#68609 / #66037):
|
|
explicit ``communicate(timeout)``, tree-kill on failure, bounded 1s drain,
|
|
then abandon.
|
|
|
|
These tests use REAL subprocesses (no mocks) for the semantics: a mock cannot
|
|
reproduce pipe-handle inheritance or timeout behavior. The POSIX-only
|
|
descendant-survival cases live in ``test_git_probe_tree_kill.py`` and now
|
|
exercise the same code path through the ``bounded_git_probe`` delegation.
|
|
"""
|
|
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from hermes_cli._subprocess_compat import bounded_git_probe, bounded_probe_run
|
|
|
|
_PY = sys.executable
|
|
|
|
|
|
def test_success_returns_completed_process():
|
|
result = bounded_probe_run([_PY, "-c", "print('ok'); import sys; sys.exit(0)"], timeout=30)
|
|
assert result is not None
|
|
assert result.returncode == 0
|
|
assert result.stdout.strip() == "ok"
|
|
|
|
|
|
def test_nonzero_exit_is_returned_not_swallowed():
|
|
"""Unlike bounded_git_probe, callers see the real returncode — the gateway
|
|
scan branches on ``returncode != 0`` to trip the wmic→powershell fallback."""
|
|
result = bounded_probe_run([_PY, "-c", "print('partial'); import sys; sys.exit(3)"], timeout=30)
|
|
assert result is not None
|
|
assert result.returncode == 3
|
|
assert result.stdout.strip() == "partial"
|
|
|
|
|
|
def test_spawn_failure_returns_none():
|
|
result = bounded_probe_run(["definitely-not-a-real-binary-87134"], timeout=5)
|
|
assert result is None
|
|
|
|
|
|
def test_timeout_returns_none_within_bounded_time():
|
|
"""A child that sleeps past the timeout must produce ``None`` promptly —
|
|
timeout + tree-kill + 1s bounded drain, not an unbounded join."""
|
|
start = time.monotonic()
|
|
result = bounded_probe_run(
|
|
[_PY, "-c", "import time; time.sleep(300)"],
|
|
timeout=1.0,
|
|
)
|
|
elapsed = time.monotonic() - start
|
|
assert result is None
|
|
# 1s timeout + tree-kill + 1s drain + slack. The pre-fix failure mode is
|
|
# an indefinite hang, so any bound proves the property; keep it loose for
|
|
# slow CI runners.
|
|
assert elapsed < 30
|
|
|
|
|
|
def test_decode_errors_configurable():
|
|
"""The process scans pass errors='ignore' (wmic emits system code page);
|
|
undecodable bytes must not raise or None out stdout (#17049 class)."""
|
|
result = bounded_probe_run(
|
|
[_PY, "-c", "import sys; sys.stdout.buffer.write(b'ok\\xff\\xfe')"],
|
|
timeout=30,
|
|
errors="ignore",
|
|
)
|
|
assert result is not None
|
|
assert result.returncode == 0
|
|
assert "ok" in result.stdout
|
|
|
|
|
|
def test_stdin_is_devnull_not_inherited():
|
|
"""A probe must never block reading the caller's stdin."""
|
|
result = bounded_probe_run(
|
|
[_PY, "-c", "import sys; print(repr(sys.stdin.read()))"],
|
|
timeout=30,
|
|
)
|
|
assert result is not None
|
|
assert result.returncode == 0
|
|
assert result.stdout.strip() == "''"
|
|
|
|
|
|
def test_bounded_git_probe_delegates_same_contract():
|
|
"""The historical git-probe wrapper keeps its exact contract on top of
|
|
bounded_probe_run: stripped stdout on rc==0, '' on any failure."""
|
|
assert bounded_git_probe([_PY, "-c", "print(' x ')"], timeout=30) == "x"
|
|
assert bounded_git_probe([_PY, "-c", "import sys; sys.exit(1)"], timeout=30) == ""
|
|
assert bounded_git_probe(["definitely-not-a-real-binary-87134"], timeout=5) == ""
|
|
|
|
|
|
@pytest.mark.platforms("posix") # POSIX process-group check
|
|
def test_posix_child_gets_own_process_group():
|
|
"""POSIX spawns use process_group=0 so timeout cleanup can killpg the
|
|
whole tree (same contract bounded_git_probe had)."""
|
|
result = bounded_probe_run(
|
|
[_PY, "-c", "import os; print(os.getpgid(0) == os.getpid())"],
|
|
timeout=30,
|
|
)
|
|
assert result is not None
|
|
assert result.stdout.strip() == "True"
|