fix(profiles): scope the detached models-rebuild worker to the active profile (#3957)
Codex + Opus both independently caught a CORE gap in the first cut: the bounded /api/models rebuild runs on a detached 'models-catalog-rebuild' daemon thread that inherits neither the request-profile thread-local (#798) nor os.environ. So on a non-default profile the worker probed the DEFAULT profile's credentials and, when the 4s budget was exceeded, published the rebuilt catalog to the DEFAULT profile's disk cache (cross-contamination) — exactly the slow path a non-default cold rebuild takes. Fix: - profile_scope_for_detached_worker(profile_name): sets the request-profile TLS AND applies the profile .env on the worker thread, restoring both on exit (no-op for default). Distinct from profile_env_for_active_request (which reads the current thread's TLS and must not clear it). - get_available_models() captures the active profile on the request thread and wraps the rebuild worker body (probe + over-budget publish + disk save + fingerprint) in that scope; the legacy synchronous rebuild applies the profile env on the foreground. /api/models route no longer wraps (the work moved into get_available_models so ALL callers — chat/start, resolution — are fixed). - 2 new regression tests incl. the worker-thread before/inside/after assertion. Empirically verified: a fresh worker thread resolved models_cache.json/default WITHOUT the scope (the bug) and models_cache.work.json + the work .env + the work auth.json WITH it. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
1 parent
70d391c44a
commit
b5d382a3bb
4 files changed
+183
-27
No files matched your search
+59
-15
@@ -5250,10 +5250,40 @@ def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
with _cache_build_cv:
|
||||
_cache_build_in_progress = True
|
||||
|
||||
# Capture the active per-request profile (#3957). The live provider
|
||||
# probe inside the rebuild resolves credentials from os.environ /
|
||||
# HERMES_HOME and the disk-cache path/fingerprint from the profile TLS;
|
||||
# the detached worker thread below inherits NEITHER, so it must be
|
||||
# captured here (on the request thread, where the TLS is valid) and
|
||||
# re-bound on the worker. Empty / default for single-profile installs.
|
||||
from contextlib import nullcontext as _nullcontext
|
||||
|
||||
_active_profile_name = ""
|
||||
_prof_env_request = None
|
||||
_prof_scope_worker = None
|
||||
try:
|
||||
from api.profiles import (
|
||||
get_active_profile_name as _gapn,
|
||||
profile_env_for_active_request as _prof_env_request,
|
||||
profile_scope_for_detached_worker as _prof_scope_worker,
|
||||
)
|
||||
_active_profile_name = (_gapn() or "").strip()
|
||||
except Exception:
|
||||
_prof_env_request = None
|
||||
_prof_scope_worker = None
|
||||
|
||||
# Legacy synchronous (unbounded) rebuild — opt-in via budget<=0.
|
||||
if _LIVE_REBUILD_BUDGET_SECONDS <= 0:
|
||||
try:
|
||||
result = _invoke_models_rebuild(_build_available_models_uncached)
|
||||
# Foreground thread already carries the request-profile TLS;
|
||||
# apply the profile env (no-op for default) for the live probe.
|
||||
_sync_scope = (
|
||||
_prof_env_request("models rebuild (sync)")
|
||||
if _prof_env_request is not None
|
||||
else _nullcontext()
|
||||
)
|
||||
with _sync_scope:
|
||||
result = _invoke_models_rebuild(_build_available_models_uncached)
|
||||
except BaseException:
|
||||
# Always reset the flag so waiting threads don't block for 60s
|
||||
with _cache_build_cv:
|
||||
@@ -5329,20 +5359,34 @@ def get_available_models(*, prefer_cache: bool = False) -> dict:
|
||||
return True
|
||||
|
||||
def _rebuild_worker():
|
||||
try:
|
||||
box["result"] = _invoke_models_rebuild(_build_available_models_uncached)
|
||||
except Exception as exc: # noqa: BLE001 — propagated to caller
|
||||
box["error"] = exc
|
||||
finally:
|
||||
build_done.set()
|
||||
# Only publish out-of-band if the foreground already gave up
|
||||
# (over budget). Within budget the foreground publishes
|
||||
# synchronously, so the worker must NOT touch the cache.
|
||||
if budget_exceeded.is_set() and _claim_publish():
|
||||
if "result" in box:
|
||||
_publish_models_result(box["result"])
|
||||
else:
|
||||
_clear_build_in_progress()
|
||||
# Re-bind the captured per-request profile on THIS worker thread
|
||||
# (#3957): the daemon inherits neither the request-profile TLS nor
|
||||
# os.environ, so without this it would probe the default profile's
|
||||
# credentials and, over budget, publish the rebuilt catalog to the
|
||||
# DEFAULT profile's disk cache. No-op for the default profile.
|
||||
_worker_scope = (
|
||||
_prof_scope_worker(_active_profile_name, "models rebuild (worker)")
|
||||
if _prof_scope_worker is not None
|
||||
else _nullcontext()
|
||||
)
|
||||
with _worker_scope:
|
||||
try:
|
||||
box["result"] = _invoke_models_rebuild(_build_available_models_uncached)
|
||||
except Exception as exc: # noqa: BLE001 — propagated to caller
|
||||
box["error"] = exc
|
||||
finally:
|
||||
build_done.set()
|
||||
# Only publish out-of-band if the foreground already gave up
|
||||
# (over budget). Within budget the foreground publishes
|
||||
# synchronously, so the worker must NOT touch the cache.
|
||||
# NOTE: the publish (and its disk write + fingerprint) runs
|
||||
# INSIDE this profile scope so the over-budget path writes
|
||||
# the correct profile's cache file.
|
||||
if budget_exceeded.is_set() and _claim_publish():
|
||||
if "result" in box:
|
||||
_publish_models_result(box["result"])
|
||||
else:
|
||||
_clear_build_in_progress()
|
||||
|
||||
_worker = threading.Thread(
|
||||
target=_rebuild_worker,
|
||||
|
||||
@@ -821,6 +821,49 @@ def profile_env_for_active_request(
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def profile_scope_for_detached_worker(
|
||||
profile_name,
|
||||
purpose: str = "detached worker",
|
||||
logger_override: Optional[logging.Logger] = None,
|
||||
):
|
||||
"""Bind BOTH the per-request profile TLS and the profile env on a NEW thread (#3957).
|
||||
|
||||
A detached worker thread (e.g. the ``models-catalog-rebuild`` daemon that
|
||||
``get_available_models`` spawns for a bounded rebuild) inherits neither the
|
||||
spawning request's profile thread-local (issue #798) nor its ``os.environ``.
|
||||
Without re-establishing both, the worker resolves the *default* profile:
|
||||
- profile-keyed paths (``_get_models_cache_path`` / ``_get_config_path`` /
|
||||
``_get_auth_store_path`` / ``_models_cache_source_fingerprint``) read the
|
||||
per-request profile via ``get_active_profile_name()`` — needs the TLS;
|
||||
- credential lookups (``provider_model_ids`` / ``_lookup_custom_api_key_env``)
|
||||
read ``os.environ`` — needs the profile ``.env`` applied.
|
||||
|
||||
Pass the profile name CAPTURED on the spawning thread (where the TLS is
|
||||
valid) into the worker, then enter this scope at the top of the worker body.
|
||||
It sets the request-profile TLS for this (worker) thread and applies the
|
||||
profile env via ``profile_env_for_background_worker``, restoring both on exit.
|
||||
No-op for the default/root profile.
|
||||
|
||||
Unlike ``profile_env_for_active_request`` (which reads the *current* thread's
|
||||
TLS and must NOT clear it — the request thread keeps using it after the call),
|
||||
this sets and then CLEARS the TLS, which is correct for a dedicated worker
|
||||
thread that has no other use for it.
|
||||
"""
|
||||
name = (profile_name or "").strip()
|
||||
if not name or _is_root_profile(name):
|
||||
yield
|
||||
return
|
||||
set_request_profile(name)
|
||||
try:
|
||||
with profile_env_for_background_worker(
|
||||
name, purpose, logger_override=logger_override
|
||||
):
|
||||
yield
|
||||
finally:
|
||||
clear_request_profile()
|
||||
|
||||
|
||||
def _set_hermes_home(home: Path):
|
||||
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
|
||||
os.environ['HERMES_HOME'] = str(home)
|
||||
|
||||
+6
-6
@@ -5620,12 +5620,12 @@ def handle_get(handler, parsed) -> bool:
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/models":
|
||||
# Apply the active per-request profile's env so the model catalog
|
||||
# resolves against that profile's credentials, not the process-default
|
||||
# profile's (#3957). No-op for the default profile.
|
||||
from api.profiles import profile_env_for_active_request
|
||||
with profile_env_for_active_request("/api/models", logger_override=logger):
|
||||
return j(handler, get_available_models())
|
||||
# Profile-scoping for non-default profiles (#3957) is handled INSIDE
|
||||
# get_available_models() — it binds the active profile's env + TLS on
|
||||
# the detached rebuild worker (and the legacy synchronous rebuild),
|
||||
# which the request-thread wrapper could not reach. See
|
||||
# api.config.get_available_models cold path + profile_scope_for_detached_worker.
|
||||
return j(handler, get_available_models())
|
||||
|
||||
if parsed.path == "/api/models/live":
|
||||
return _handle_live_models(handler, parsed)
|
||||
|
||||
@@ -168,15 +168,84 @@ def test_active_request_env_restores_on_exception(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_providers_and_models_routes_wrap_in_profile_env():
|
||||
"""The two read routes invoke the profile-env context manager (#3957).
|
||||
"""The two read routes are profile-scoped for non-default profiles (#3957).
|
||||
|
||||
Structural guard: a future refactor that drops the wrapper would silently
|
||||
reintroduce the bug, so pin the wiring at the source level.
|
||||
Structural guard: a future refactor that drops the wiring would silently
|
||||
reintroduce the bug, so pin it at the source level.
|
||||
- /api/providers wraps the synchronous read in profile_env_for_active_request.
|
||||
- /api/models relies on get_available_models() scoping its detached
|
||||
rebuild worker via profile_scope_for_detached_worker (the request-thread
|
||||
wrapper cannot reach the worker thread — Codex CORE finding).
|
||||
"""
|
||||
routes_src = Path(profiles.__file__).resolve().parent.joinpath("routes.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# Both endpoint blocks must reference the active-request profile-env wrapper.
|
||||
assert "profile_env_for_active_request" in routes_src
|
||||
# And it must be imported from the profiles module in routes.py.
|
||||
assert routes_src.count("profile_env_for_active_request") >= 3 # 2 calls + >=1 import
|
||||
config_src = Path(config.__file__).resolve().read_text(encoding="utf-8")
|
||||
assert "profile_scope_for_detached_worker" in config_src
|
||||
assert "_get_models_cache_path" in config_src
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Facet A (worker thread) — the detached models-rebuild worker is profile-scoped
|
||||
# (Codex CORE finding: the request-thread wrapper cannot reach the worker thread)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_detached_worker_scope_noop_for_default_profile(monkeypatch):
|
||||
"""profile_scope_for_detached_worker is a no-op for the default profile."""
|
||||
monkeypatch.setattr(profiles, "_is_root_profile", lambda n: n in ("", "default"))
|
||||
monkeypatch.delenv("ISSUE_3957_WPROBE", raising=False)
|
||||
# Default/empty name → no TLS set, no env applied.
|
||||
with profiles.profile_scope_for_detached_worker("default", "test"):
|
||||
assert profiles.get_active_profile_name() in ("", "default")
|
||||
assert os.environ.get("ISSUE_3957_WPROBE") is None
|
||||
with profiles.profile_scope_for_detached_worker("", "test"):
|
||||
assert os.environ.get("ISSUE_3957_WPROBE") is None
|
||||
|
||||
|
||||
def test_detached_worker_scope_binds_profile_on_new_thread(monkeypatch, tmp_path):
|
||||
"""A worker thread re-binds the captured profile's TLS + env + cache path.
|
||||
|
||||
Reproduces the Codex CORE finding: WITHOUT the scope a new thread resolves
|
||||
the default profile (cache path models_cache.json, no profile env); WITH it
|
||||
the thread resolves the captured profile's cache file + .env.
|
||||
"""
|
||||
import threading
|
||||
|
||||
base = tmp_path / ".hermes"
|
||||
(base / "profiles" / "work").mkdir(parents=True)
|
||||
(base / "profiles" / "work" / ".env").write_text(
|
||||
"ISSUE_3957_WPROBE=worker-env\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
|
||||
# Point the default models cache at an isolated tmp file so the named path
|
||||
# derives from it (models_cache.work.json under the same dir).
|
||||
default_cache = tmp_path / "state" / "models_cache.json"
|
||||
default_cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(config, "_models_cache_path", default_cache)
|
||||
monkeypatch.delenv("ISSUE_3957_WPROBE", raising=False)
|
||||
|
||||
out = {}
|
||||
|
||||
def worker():
|
||||
# No TLS on this fresh thread yet → default profile resolution (the bug).
|
||||
out["before_name"] = config._get_models_cache_path().name
|
||||
out["before_env"] = os.environ.get("ISSUE_3957_WPROBE")
|
||||
with profiles.profile_scope_for_detached_worker("work", "test-worker"):
|
||||
out["inside_name"] = config._get_models_cache_path().name
|
||||
out["inside_env"] = os.environ.get("ISSUE_3957_WPROBE")
|
||||
out["after_name"] = config._get_models_cache_path().name
|
||||
out["after_env"] = os.environ.get("ISSUE_3957_WPROBE")
|
||||
|
||||
t = threading.Thread(target=worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
assert out["before_name"] == "models_cache.json" # default (no scope)
|
||||
assert out["before_env"] is None
|
||||
assert out["inside_name"] == "models_cache.work.json" # profile-scoped
|
||||
assert out["inside_env"] == "worker-env"
|
||||
assert out["after_name"] == "models_cache.json" # restored
|
||||
assert out["after_env"] is None
|
||||
|
||||
Reference in new issue
Block a user