Files
hermes-agent/tests/hermes_cli/test_install_lock_timeout.py
ethernet 398867326d fix(voice): requirement checks answer from state, never install
Provider RESOLUTION ran an installer. `_detect_local_backend` called
`_try_lazy_install_stt` -> `pm.ensure_import`, and the TTS requirement entries called the
SDK importers, which install their extra on import. Both sit in status paths:
`wake.status` fires on every desktop gateway-open, `/voice status` and
`GET /api/audio/voice-config` resolve the same chain, and `check_tts_requirements` is the
`text_to_speech` tool's `check_fn`, so it runs on every tool listing.

The install is a whole-environment rebuild (a fresh generation, `--all-packages` over the
enabled set) under the per-install lock, so a passive probe could hold a sibling profile's
backend off its port for tens of seconds — the `hermes-setup` onboarding backend gave up at
40 s while an unrelated status probe's rebuild held the lock.

Availability now reads state: `pm.extras.available` for the extra (pm owns the extra->anchor
table, counts sys.modules fakes, and honours platform gates), `find_spec` for the rest. A
provider whose SDK installs at first use still counts as READY when lazy installs are allowed
and its own credential and platform gate permit — otherwise the tool would vanish from the
schema for exactly the users that install serves. Synthesis keeps its on-demand install, and
the lazy STT path now asks for `stt-whisper`, the extra carrying only faster-whisper.

Validated: tests/tools/ for the TTS/STT suites (test_tts_mistral's importable-SDK contract
included). A/B on HEAD: the two new files' non-guard tests fail there (3 + 1), pass here; the
guard test that transcription still installs passes on both.
2026-09-15 10:48:09 -04:00

89 lines
3.4 KiB
Python

"""A backend binds even while another process holds the install lock.
Regression: ``runtime_lock`` waited forever, and the lock is held across a whole dependency
rebuild (tens of seconds on a bundle), so the second backend — the ``hermes-setup`` onboarding
profile — never reached its port: the renderer gave up at 40 s and Electron killed it at 90 s
while the holder ran a uv build it had started for a status probe. Deciding what a lost race
means is the caller's job now: readers skip, explicit installs still wait.
"""
import json
import os
import subprocess
import sys
import pytest
from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path, site_packages
_HOLDER = """
import sys
from pathlib import Path
from hermes_cli.runtime_state import runtime_lock
with runtime_lock(Path(sys.argv[1]), timeout=0):
print("locked", flush=True)
sys.stdin.readline()
"""
@pytest.fixture
def install_tree(tmp_path, monkeypatch):
"""A real committed generation, ready for ``activate_dependencies`` to select."""
repo = tmp_path / "repo"
repo.mkdir()
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
environment = install_state_dir(repo) / "environments" / "gen1" / "venv"
site = site_packages(environment)
site.mkdir(parents=True)
(environment / "pyvenv.cfg").write_text("version = 3.14", encoding="utf-8")
(environment.parent / ".lease-managed").touch()
runtime_facts_path(repo).write_text(
json.dumps({"packages": {"venv": {"environment": str(environment)}}}), encoding="utf-8")
return repo, site
@pytest.fixture
def locked_install(install_tree):
"""A live second process holding ``.install.lock``, the way a sibling backend does."""
repo, site = install_tree
holder = subprocess.Popen([sys.executable, "-c", _HOLDER, str(repo)], env=dict(os.environ),
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
assert holder.stdout is not None and holder.stdin is not None
assert holder.stdout.readline().strip() == "locked"
try:
yield repo, site
finally:
holder.communicate("done\n", timeout=15)
def test_runtime_lock_reports_a_lost_race(locked_install):
from hermes_cli.runtime_state import runtime_lock
repo, _site = locked_install
with runtime_lock(repo, timeout=0.2) as held:
assert held is False
# The blocked-install symptom: a bounded wait, then carry on — never the holder's rebuild.
def test_boot_activation_proceeds_while_the_install_is_locked(locked_install, monkeypatch):
"""The issue's symptom, inverted: the backend reaches its dependency environment and can bind
while a sibling holds the lock. Recovery belongs to whoever holds it, so it is skipped."""
import hermes_cli.runtime_paths as runtime_paths
import hermes_cli.runtime_state as runtime_state
repo, site = locked_install
real_lock = runtime_state.runtime_lock
monkeypatch.setattr(runtime_state, "runtime_lock",
lambda project, **kw: real_lock(project, timeout=0.2))
recovered: list = []
monkeypatch.setattr(runtime_state, "recover_publication", lambda project: recovered.append(project))
saved_path = sys.path[:]
try:
runtime_paths.activate_dependencies(repo)
assert str(site) in sys.path
finally:
sys.path[:] = saved_path
assert recovered == []