GET /api/profiles, the profiles.list RPC and the /api/profiles/projects/tree
fan-out are polled by the Desktop every few seconds (roster tick, focus,
gateway-open). Each call ran list_profiles() -> _count_skills() ->
Path.rglob("SKILL.md") over EVERY profile once the 30 s TTL expired: ~4 fs
calls per skill, 2.4-4.7 s per walk on 7-84 profile installs, ~half a core
at idle, and on Linux enough to starve the renderer's 60 s API timeout. A
skill dir removed mid-walk raised FileNotFoundError out of rglob and aborted
the whole profile list.
- list_profiles(lazy_skill_count=True): skill_count is the last known value;
a missing/aged entry schedules ONE background _count_skills per profile per
60 s recheck window, so the request thread does zero skill-tree I/O and the
refresh cadence is decoupled from the poll rate. The two polled callers and
the REST fallback entry use it; the sync default (CLI, detail views) is
unchanged.
- _walk_skill_count: the repo walker (agent.skill_utils.iter_skill_index_files,
os.walk with excluded/support dirs pruned) instead of rglob — half the
fs calls and best-effort on subtrees that vanish mid-walk. profiles.describe
uses the same walker.
- _profile_targets always uses profiles_to_serve (pure directory read):
projects/tree and sessions/pull-requests only ever consumed name/path.
- Skill-count TTL 30 s -> 600 s (signature invalidation still catches
skill add/remove immediately on the next refresh).
Live repro (5 profiles x 200 SKILL.md, temp HERMES_HOME, py3.11):
before: GET /api/profiles 2175 stat + 2015 scandir per cold call,
profiles.list 2189 + 2015, projects/tree 2177 + 2015;
a skill dir removed mid-walk -> FileNotFoundError from list_profiles()
after: GET /api/profiles 0 skill-tree stats/scandir on the request thread
(counts land from the background refresh by the next poll),
profiles.list 0, projects/tree 0; _count_skills (detail/control)
still reports 200 with 203 scandir; the vanished-dir case returns
199 and list_profiles() enumerates all 5 profiles.
Fixes #114041
Co-authored-by: KoNit-K <konit.block@protonmail.com>
69 lines
2.3 KiB
Python
69 lines
2.3 KiB
Python
"""GET /api/profiles coalesces a poll burst before AnyIO threadpool admission (#105179)."""
|
|
import asyncio
|
|
import threading
|
|
from functools import wraps
|
|
|
|
import pytest
|
|
|
|
|
|
async def wait_until(predicate):
|
|
async with asyncio.timeout(3):
|
|
while not predicate():
|
|
await asyncio.sleep(0.005)
|
|
|
|
|
|
def async_test(fn):
|
|
@wraps(fn)
|
|
def run(*args, **kwargs):
|
|
return asyncio.run(fn(*args, **kwargs))
|
|
return run
|
|
|
|
|
|
@async_test
|
|
async def test_profiles_burst_leaves_threadpool_status_responsive(monkeypatch):
|
|
import anyio.to_thread
|
|
import httpx
|
|
from fastapi import FastAPI
|
|
from hermes_cli import profiles as profiles_mod
|
|
from hermes_cli.web_routers import profiles
|
|
from starlette.concurrency import run_in_threadpool
|
|
release = threading.Event()
|
|
calls = []
|
|
|
|
def slow_profiles(**_kwargs): # the router passes ``lazy_skill_count=True``
|
|
calls.append(1)
|
|
assert release.wait(3)
|
|
return ["example"]
|
|
|
|
monkeypatch.setattr(profiles_mod, "list_profiles", slow_profiles)
|
|
monkeypatch.setattr(profiles, "_profile_to_dict", lambda p: {"name": p})
|
|
monkeypatch.setattr(profiles, "run_in_threadpool", run_in_threadpool)
|
|
app = FastAPI()
|
|
app.include_router(profiles.router)
|
|
|
|
@app.get("/api/status")
|
|
def status():
|
|
return {"ok": True}
|
|
|
|
limiter = anyio.to_thread.current_default_thread_limiter()
|
|
previous = limiter.total_tokens
|
|
limiter.total_tokens = 2
|
|
try:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
|
tasks = [asyncio.create_task(client.get("/api/profiles")) for _ in range(12)]
|
|
try:
|
|
await wait_until(lambda: calls)
|
|
await asyncio.sleep(0.05)
|
|
response = await asyncio.wait_for(client.get("/api/status"), 0.5)
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True}
|
|
assert calls == [1]
|
|
assert limiter.borrowed_tokens == 1
|
|
finally:
|
|
release.set()
|
|
responses = await asyncio.gather(*tasks)
|
|
assert all(r.status_code == 200 for r in responses)
|
|
assert all(r.json() == {"profiles": [{"name": "example"}]} for r in responses)
|
|
finally:
|
|
limiter.total_tokens = previous
|