test(ssh-ownership): remove process-global patches that crashed sibling threads
De-flakes tests/hermes_cli/test_ssh_ownership_endpoint.py, which failed CI twice on PR #95563 with teardown-time daemon-thread excepthook crashes — a different test in the file each attempt, always green in isolation. Root cause: three PROCESS-GLOBAL monkeypatches leaked into every other thread sharing the per-file worker: - monkeypatch.setattr(web_server.os, 'stat', ...) — web_server.os IS the os module; any daemon thread from an earlier test that stat()ed during the patch window got the fake 2-field stat and died in its excepthook, which fired at interpreter teardown. - monkeypatch.setattr('builtins.open', ...) — same class, worse blast radius. - monkeypatch.setattr(web_server.sysconfig, 'get_paths', ...) — sysconfig is process-global too. Fixes, none of which weaken coverage: - replaced-runtime test: a REAL tmp_path purelib whose recorded inode deliberately mismatches (st_ino + 1) — real os.stat, same code path. - readonly-purelib test: chmod 0o555 on the real directory instead of an open() interceptor — exercises the genuine OSError branch (root-skipped, where mode bits aren't enforced). - sysconfig patches swapped for a SimpleNamespace on the web_server module attribute — module-scoped, invisible to other threads. Verified: 14 consecutive full-file runs green; sabotaging _ssh_runtime_intact to always-True still fails 2 tests (coverage intact).
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import os
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
@@ -25,13 +29,23 @@ def test_ssh_ownership_endpoint_requires_token_and_returns_exact_nonce(monkeypat
|
||||
}
|
||||
|
||||
|
||||
def test_ssh_ownership_reports_replaced_runtime(monkeypatch):
|
||||
def test_ssh_ownership_reports_replaced_runtime(tmp_path, monkeypatch):
|
||||
token = "t" * 64
|
||||
monkeypatch.setattr(web_server, "_SESSION_TOKEN", token)
|
||||
monkeypatch.setattr(web_server, "_SSH_OWNER_NONCE", "0123456789abcdef")
|
||||
monkeypatch.setattr(web_server, "_SSH_RUNTIME_MARKER", None)
|
||||
monkeypatch.setattr(web_server, "_SSH_RUNTIME_PURELIB", ("/venv/site-packages", 10, 20))
|
||||
monkeypatch.setattr(web_server.os, "stat", lambda _path: type("Stat", (), {"st_dev": 10, "st_ino": 21})())
|
||||
# A REAL purelib file whose recorded inode deliberately mismatches what
|
||||
# os.stat now reports — never patch os.stat globally here: web_server.os
|
||||
# is the os module itself, and swapping its stat() poisons every other
|
||||
# thread in this worker process (daemon threads from earlier tests crash
|
||||
# in their excepthooks → nondeterministic teardown errors across the
|
||||
# whole suite, the Aug 2026 CI flake).
|
||||
purelib = tmp_path / "site-packages"
|
||||
purelib.mkdir()
|
||||
st = purelib.stat()
|
||||
monkeypatch.setattr(
|
||||
web_server, "_SSH_RUNTIME_PURELIB", (str(purelib), st.st_dev, st.st_ino + 1)
|
||||
)
|
||||
client = TestClient(web_server.app)
|
||||
|
||||
response = client.get("/api/ssh/ownership", headers={"X-Hermes-Session-Token": token})
|
||||
@@ -49,10 +63,14 @@ def test_ssh_runtime_marker_detects_recreated_venv_even_with_reused_inode(
|
||||
file is the deterministic tier: it dies with the old tree."""
|
||||
purelib = tmp_path / "venv" / "lib" / "site-packages"
|
||||
purelib.mkdir(parents=True)
|
||||
# Swap the MODULE ATTRIBUTE on web_server, not sysconfig.get_paths itself:
|
||||
# sysconfig is process-global, and mutating it races every other thread
|
||||
# in the worker (same cross-thread poisoning class as the os.stat patch
|
||||
# this file used to have).
|
||||
monkeypatch.setattr(
|
||||
web_server.sysconfig,
|
||||
"get_paths",
|
||||
lambda *a, **k: {"purelib": str(purelib)},
|
||||
web_server,
|
||||
"sysconfig",
|
||||
types.SimpleNamespace(get_paths=lambda *a, **k: {"purelib": str(purelib)}),
|
||||
)
|
||||
|
||||
web_server._apply_ssh_owner_nonce("0123456789abcdef")
|
||||
@@ -76,10 +94,14 @@ def test_ssh_runtime_marker_survives_in_place_installs(tmp_path, monkeypatch):
|
||||
"""pip/uv installs INTO the live venv must not read as a replacement."""
|
||||
purelib = tmp_path / "venv" / "lib" / "site-packages"
|
||||
purelib.mkdir(parents=True)
|
||||
# Swap the MODULE ATTRIBUTE on web_server, not sysconfig.get_paths itself:
|
||||
# sysconfig is process-global, and mutating it races every other thread
|
||||
# in the worker (same cross-thread poisoning class as the os.stat patch
|
||||
# this file used to have).
|
||||
monkeypatch.setattr(
|
||||
web_server.sysconfig,
|
||||
"get_paths",
|
||||
lambda *a, **k: {"purelib": str(purelib)},
|
||||
web_server,
|
||||
"sysconfig",
|
||||
types.SimpleNamespace(get_paths=lambda *a, **k: {"purelib": str(purelib)}),
|
||||
)
|
||||
|
||||
web_server._apply_ssh_owner_nonce("0123456789abcdef")
|
||||
@@ -95,27 +117,33 @@ def test_ssh_runtime_readonly_purelib_falls_back_to_stat(tmp_path, monkeypatch):
|
||||
stat-snapshot fallback still arms (weaker, never a false stale)."""
|
||||
purelib = tmp_path / "venv" / "lib" / "site-packages"
|
||||
purelib.mkdir(parents=True)
|
||||
# Swap the MODULE ATTRIBUTE on web_server, not sysconfig.get_paths itself:
|
||||
# sysconfig is process-global, and mutating it races every other thread
|
||||
# in the worker (same cross-thread poisoning class as the os.stat patch
|
||||
# this file used to have).
|
||||
monkeypatch.setattr(
|
||||
web_server.sysconfig,
|
||||
"get_paths",
|
||||
lambda *a, **k: {"purelib": str(purelib)},
|
||||
web_server,
|
||||
"sysconfig",
|
||||
types.SimpleNamespace(get_paths=lambda *a, **k: {"purelib": str(purelib)}),
|
||||
)
|
||||
real_open = open
|
||||
|
||||
def refuse_marker(path, *a, **k):
|
||||
if ".hermes-ssh-runtime-" in str(path):
|
||||
raise OSError(30, "Read-only file system")
|
||||
return real_open(path, *a, **k)
|
||||
|
||||
monkeypatch.setattr("builtins.open", refuse_marker)
|
||||
|
||||
web_server._apply_ssh_owner_nonce("0123456789abcdef")
|
||||
# Make the directory REALLY unwritable instead of patching builtins.open:
|
||||
# a global open() patch races every other thread in the worker process
|
||||
# (daemon threads crash in their excepthooks → nondeterministic teardown
|
||||
# errors file-wide, the Aug 2026 CI flake). chmod is thread-safe and
|
||||
# exercises the genuine OSError path.
|
||||
if os.geteuid() == 0: # pragma: no cover - root ignores mode bits
|
||||
pytest.skip("directory write bits are not enforced for root")
|
||||
purelib.chmod(0o555)
|
||||
try:
|
||||
assert web_server._SSH_RUNTIME_MARKER is None
|
||||
assert web_server._SSH_RUNTIME_PURELIB is not None
|
||||
assert web_server._ssh_runtime_intact() is True
|
||||
web_server._apply_ssh_owner_nonce("0123456789abcdef")
|
||||
try:
|
||||
assert web_server._SSH_RUNTIME_MARKER is None
|
||||
assert web_server._SSH_RUNTIME_PURELIB is not None
|
||||
assert web_server._ssh_runtime_intact() is True
|
||||
finally:
|
||||
web_server._apply_ssh_owner_nonce(None)
|
||||
finally:
|
||||
web_server._apply_ssh_owner_nonce(None)
|
||||
purelib.chmod(0o755) # let tmp_path cleanup succeed
|
||||
|
||||
|
||||
def test_ssh_ownership_endpoint_is_absent_without_owner_nonce(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user