Files
hermes-agent/tests/hermes_cli/test_config_read_guard.py
T
ethernet 969094e4d2 fix(tests): remove four shared-state and lifetime faults at high concurrency
The suite now runs as one job with high per-file concurrency. Four tests
depend on state that they share with their siblings, or on a timer that
outlives them. That was safe at 8 workers. It is not safe at 96 or more.
Runs 32547184159 and 32551746525 show them.

1. Every pytest subprocess shared one temp root.

pytest puts tmp_path under <temproot>/pytest-of-<user>/. At the end of a
session it walks that directory with cleanup_dead_symlinks(). The walk lists
the directory. Then it asks whether the `pytest-current` symlink resolves.
Then it unlinks the symlink. A second process replaces that symlink between
the question and the unlink. The first process then raises FileNotFoundError
after all of its tests passed. Two files failed this way and passed on retry.

scripts/run_tests_parallel.py now gives each subprocess its own temp root
through PYTEST_DEBUG_TEMPROOT, and deletes it after the attempt. No two
processes share a directory. The race has no shared object to act on.

Proof: a direct driver of _pytest.pathlib.cleanup_dead_symlinks against one
root, with a second thread that replaces the symlink, raises the same
FileNotFoundError on 'pytest-current' as CI. A private root for each
subprocess removes that condition. A separate check confirms that 5
subprocesses receive 5 distinct roots, that tmp_path lands inside the private
root, and that no root survives the attempt.

2. The config read guard walked directories that other tests were writing.

tests/hermes_cli/test_config_read_guard.py scanned the tree with rglob. rglob
descends into every directory and filters after that, so it calls scandir() on
__pycache__ trees that the guard never inspects. Sibling processes create and
delete those entries during the run. A directory that disappears in the middle
of a walk raises FileNotFoundError out of rglob.

The scan now uses os.walk. It prunes excluded directories before it descends,
and it ignores a directory that disappears. __pycache__ joins the excluded
set, because bytecode is not source.

The guard still catches what it exists to catch. With a planted raw
yaml.safe_load of config.yaml in hermes_cli/, the test fails and names the
planted file. With a clean tree it passes.

3. A PTY test waited for a file to exist, and not for its content.

tests/tools/test_process_registry_write_stdin_surrogates.py spawns a child
that runs open(out,'wb').write(sys.stdin.buffer.readline()). open() creates
the file empty. The bytes arrive only after the PTY delivers the line. The
wait stopped at out.exists(), which the empty file already satisfies, so the
read returned b'' when the parent won that gap. This test failed both attempts
in CI, and did not pass on retry.

The test now waits for the expected bytes, with a bounded deadline.

Proof: the old wait loses 6 times in 25 runs on an idle 16-core machine. The
new wait loses 0 times in 25.

4. A dialog close timer outlived the test that started it.

ConfirmDialog holds the "done" beat for 600ms after a successful confirm, then
calls onClose. The timer had no cleanup, so an unmount inside that window left
it armed. It then called onClose on a tree that is gone, which reaches
setState in the parent. vitest can tear the environment down first, and React
then reads `window` during the update:

    ReferenceError: window is not defined
     at resolveUpdatePriority (react-dom-client.development.js:1308)
     at dispatchSetState
     at Timeout.t4 [as _onTimeout] session-actions-menu.tsx:574

The frame at session-actions-menu.tsx:574 is the `onClose` prop of
DeleteSessionDialog. The owner of the timer is ConfirmDialog, which now keeps
the handle in a ref and clears it on unmount.

Zoomable had the same fault, with a 1500ms timer that clears a "copied" flag.
copy-button.tsx and tooltip.tsx already clear their timers.

Proof: a new test confirms, unmounts inside the 600ms window, then advances
the clock. Against the old code it fails with "expected onClose to not be
called at all, but actually been called 1 times". Against the new code it
passes.

Verification:
- The affected Python files and the tests of the runner itself pass under
  scripts/run_tests.sh.
- The desktop ui suite passes: 566 files, 5382 tests, and no
  "window is not defined".
- eslint reports 0 errors on apps/desktop. The 118 warnings are the state
  before this change. The two cleanup effects carry an eslint-disable line for
  the ref-mirror rule. They write a timer handle, and not a mirror of a
  reactive value. The rule permits this, and its own comment names the case.
- The PTY test cannot run on the NixOS development machine. That machine has
  no python3 outside the nix store, and the test uses the literal `python3`.
  The child exits 127 there. The fix rests on the 25-run measurement above and
  on CI.
2026-08-22 02:25:12 -04:00

130 lines
5.2 KiB
Python

"""Lint guard: no new raw yaml.safe_load(config.yaml) reads outside owner modules.
The drift class this kills: scattered ``yaml.safe_load`` reads of the user's
``config.yaml`` silently miss the managed-scope overlay, ``${ENV_VAR}``
expansion, profile-aware pathing, and root-model normalization. Each new
config feature has historically required an N-site sweep (incident chain:
9cbcc0c9c8 → 732293cf87 → b0e47a98f9 → 1928aa0443).
Canonical owners:
* ``hermes_cli/config.py`` — ``load_config()`` / ``load_config_readonly()``
(merged + managed + env-expanded), ``read_raw_config()`` and
``read_user_config_raw()`` (the ONLY legal raw primitives: write-back
round-trips + raw-file diagnostics).
* ``gateway/config.py`` — the gateway's ``load_gateway_config`` owner.
* ``gateway/run.py`` — ``_load_gateway_config()``'s monkeypatched-home
fallback path (delegates to ``read_raw_config`` when paths agree).
Everything else must import one of those. If this test fails on your new
code, use ``load_config()``/``load_config_readonly()`` for behavioral reads,
or ``read_user_config_raw()`` for write-back round-trips — do not add your
file to the allowlist without a reason of the same class.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
# Files where a yaml.safe_load near a config.yaml reference is legal.
# Keep this list SHORT and justified:
ALLOWLIST = {
# Canonical loader owners.
"hermes_cli/config.py",
"gateway/config.py",
# _load_gateway_config()'s fallback path for tests that monkeypatch
# gateway.run._hermes_home (delegates to read_raw_config otherwise).
"gateway/run.py",
# Reads the MANAGED-scope config.yaml (/etc/hermes/...), not the user's —
# it IS the overlay source; the canonical loaders call into it.
"hermes_cli/managed_scope.py",
# Parse-health probe: intentionally answers "does the raw file parse?".
"gateway/readiness.py",
}
# Directories that never count (tests may build fixture configs freely).
EXCLUDED_DIR_PARTS = {
"tests", ".venv", ".git", ".worktrees", "node_modules", "website",
"docs", "scripts", "examples", "apps",
# Compiled bytecode is not source. Sibling test processes also create
# and delete these directories while this scan walks the tree.
"__pycache__",
}
# A safe_load within this many lines of a config.yaml reference is treated
# as a raw user-config read.
PROXIMITY = 6
SAFE_LOAD_RE = re.compile(r"\bsafe_load\s*\(")
CONFIG_YAML_RE = re.compile(r"""["']config\.yaml["']""")
def _iter_source_files():
# This uses os.walk with a pruned dirnames, and not rglob. rglob descends
# into every directory and filters after that, so it calls scandir() on
# __pycache__ trees that this guard never inspects. Sibling test processes
# create and delete those entries during the run.
#
# A directory that disappears in the middle of a walk raises
# FileNotFoundError out of rglob. The test then fails for a reason that it
# does not assert.
#
# The prune skips those trees. The onerror callback ignores a directory
# that disappears anyway.
for dirpath, dirnames, filenames in os.walk(REPO_ROOT, onerror=lambda _e: None):
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIR_PARTS]
for name in filenames:
if not name.endswith(".py"):
continue
path = Path(dirpath) / name
rel = path.relative_to(REPO_ROOT)
if any(part in EXCLUDED_DIR_PARTS for part in rel.parts):
continue
yield rel, path
def test_no_raw_config_yaml_reads_outside_owner_modules():
offenders: list[str] = []
for rel, path in _iter_source_files():
rel_str = str(rel).replace("\\", "/")
if rel_str in ALLOWLIST:
continue
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
cfg_lines = [i for i, ln in enumerate(lines) if CONFIG_YAML_RE.search(ln)]
if not cfg_lines:
continue
for i, ln in enumerate(lines):
if not SAFE_LOAD_RE.search(ln):
continue
# Comment/docstring mentions don't count.
stripped = ln.strip()
if stripped.startswith("#"):
continue
if any(abs(i - j) <= PROXIMITY for j in cfg_lines):
offenders.append(f"{rel_str}:{i + 1}: {stripped}")
assert not offenders, (
"Raw yaml.safe_load of config.yaml outside allowlisted owner modules.\n"
"Behavioral reads must use hermes_cli.config.load_config()/"
"load_config_readonly() (or gateway _load_gateway_config); write-back "
"round-trips and raw-file diagnostics must use "
"hermes_cli.config.read_user_config_raw().\nOffenders:\n "
+ "\n ".join(offenders)
)
def test_read_user_config_raw_exists_and_documented():
"""The shared raw primitive must exist and carry its legality docstring."""
from hermes_cli.config import read_user_config_raw
doc = read_user_config_raw.__doc__ or ""
assert "ONLY legal for write-back round-trips and raw-file diagnostics" in doc
assert "load_config()" in doc