Skip to content

fix(serve): cache /api/status profile-gateway topology scan (GIL stalls starve desktop boot) - #71396

Closed
lost9999 wants to merge 1 commit into
NousResearch:mainfrom
lost9999:fix/status-topology-cache
Closed

fix(serve): cache /api/status profile-gateway topology scan (GIL stalls starve desktop boot)#71396
lost9999 wants to merge 1 commit into
NousResearch:mainfrom
lost9999:fix/status-topology-cache

Conversation

@lost9999

Copy link
Copy Markdown
Contributor

What does this PR do?

/api/status is the liveness probe the desktop app polls (~1/s) while waiting for the backend to become ready (the dashboard badge polls it too). Since #60537, every call runs a full profile-gateway topology scan — for each profile home: yaml.safe_load of config.yaml (pure-Python loader), psutil process-table probes, and realpath walks — inside the default executor.

On multi-profile installs those scans pile up under the poll rate and hold the GIL for 14–16 s at a stretch, starving the event loop. The WS sidecar then cannot flush gateway.ready (ws ready frame send failed), the desktop client times out and re-dials into the next stall, and after enough attempts boot escalates to the "Hermes couldn't start" overlay.

This PR memoizes the scan behind a 10 s TTL cache with a collapse lock, so concurrent polls share one scan. Topology only changes when gateways start/stop, so a ≤10 s stale badge is an acceptable trade for not starving the loop.

Measured on the affected machine (Windows 11, 7 profiles, 3 MCP servers):

before after
event loop stalled … (GIL pressure suspected) every 25–30 s, 14–16 s each, while desktop polls exactly one on cold start (first scan)
desktop boot fails with overlay (all WS dials land inside stalls) boots; WS stable over 90 s observation

py-spy stack captures during a real failing boot land squarely in the scan on executor threads:

construct_document / compose_mapping_node / ...
load (yaml\__init__.py:81)
safe_load (yaml\__init__.py:125)
_profile_platform_ports (hermes_cli\web_server.py:2947)
_collect_profile_gateway_topology (hermes_cli\web_server.py:3015)
run (concurrent\futures\thread.py:58)

Related Issue

Fixes the dominant, measured stall source in #60800 on multi-profile installs (that issue's own hypothesis section had not located this path). Perf regression introduced by #60537.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • hermes_cli/web_server.py: add _collect_profile_gateway_topology_cached() (10 s TTL, threading.Lock collapse so concurrent executor calls share one scan); the /api/status handler now calls the cached wrapper. Scan logic itself unchanged.
  • tests/test_web_server_status_topology_cache.py (new): cache hit within TTL, rescan after TTL expiry, and 8-thread concurrent-poll collapse to a single scan.

How to Test

  1. pytest tests/test_web_server_status_topology_cache.py tests/test_web_server.py -q
  2. Repro (without the patch): on a multi-profile install, launch the desktop app and watch logs/errors.log — repeating pairs of event loop stalled … GIL pressure suspected + ws ready frame send failed appear while the boot overlay shows.
  3. With the patch: at most one cold-start stall; the desktop reaches the UI and the WS stays connected.

Checklist

Code

Documentation & Housekeeping

  • I've updated relevant documentation — N/A (internal perf fix, behavior documented in code comment)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no config keys)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact — pure stdlib (threading/time), no platform-specific code
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

Failing boot (before), logs/errors.log:

19:01:39,757 WARNING hermes_cli.web_server: event loop stalled 15.8s (GIL pressure suspected)
19:01:39,873 ERROR   tui_gateway.ws: ws ready frame send failed peer=127.0.0.1:52792
19:02:03,055 WARNING hermes_cli.web_server: event loop stalled 14.5s (GIL pressure suspected)
19:02:03,181 ERROR   tui_gateway.ws: ws ready frame send failed peer=127.0.0.1:57212
19:02:36,324 WARNING hermes_cli.web_server: event loop stalled 14.4s (GIL pressure suspected)
19:02:36,434 ERROR   tui_gateway.ws: ws ready frame send failed peer=127.0.0.1:57933

After the patch (same machine, 110 s observation): a single cold-start stall, zero recurrence, desktop boots to a working UI.

@alt-glitch alt-glitch added type/perf Performance improvement or optimization P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) labels Jul 25, 2026
/api/status is the desktop's boot liveness probe (polled ~1/s) but since
NousResearch#60537 every call ran a full topology scan — per-profile yaml.safe_load
(pure-Python loader), psutil process probes, realpath walks — in the
default executor. On multi-profile installs concurrent polls pile up and
hold the GIL 14-16s, starving the event loop: the WS sidecar cannot
flush gateway.ready, the desktop times out into the next stall, and boot
escalates to the 'Hermes couldn't start' overlay (NousResearch#60800).

Memoize the scan behind a 10s TTL with a collapse lock so concurrent
polls share one scan. Topology only changes on gateway start/stop, so a
<=10s stale badge is an acceptable trade for not starving the loop. The
cache also keys on the collector's identity: tests monkeypatch
_collect_profile_gateway_topology per case, and the identity check keeps
them hermetic (a swapped collector is a miss) without a reset hook.

py-spy captures during a failing boot land in _profile_platform_ports ->
yaml.safe_load on executor threads (7 profiles, Windows). After: one
cold-start scan, zero recurring stalls, desktop boots.
@lost9999

Copy link
Copy Markdown
Contributor Author

CI slice 7 caught TestStatusEndpointTopology::test_profile_names_and_mode_public_when_auth_gated: that class monkeypatches _collect_profile_gateway_topology per test, and the warm cache from the fixture's collector leaked into the later case.

Fixed by keying the cache on the collector's identity as well as the TTL — a swapped collector is a cache miss, so monkeypatching tests stay hermetic with no reset hook, and production behavior is unchanged (the identity is constant there). Added test_topology_cache_misses_when_collector_is_swapped for that property; the full tests/hermes_cli/test_web_server_gateway_topology.py suite now passes locally alongside the new cache tests (25 passed).

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for isolating the expensive topology collector and covering TTL, expiry, concurrent collapse, and collector replacement.

Problems

  • The new comments describe current Desktop boot as repeatedly polling /api/status. Current Desktop boot instead calls /api/health at apps/desktop/electron/backend-health.ts:131-135; it reaches /api/status only for a backend without the health route (apps/desktop/electron/backend-health.ts:151-160). Please reframe this as a dashboard/status-consumer optimization.
  • tests/test_web_server_status_topology_cache.py tests the helper directly, but not the route integration. The production boundary remains hermes_cli/web_server.py:3239-3243; add two HTTP /api/status calls with a mocked collector and assert one scan within the TTL.

The underlying current-main issue remains: every status request directly schedules _collect_profile_gateway_topology at hermes_cli/web_server.py:3239-3243, while the dashboard sidebar polls that endpoint in web/src/hooks/useSidebarStatus.ts:14-22.

Automated hermes-sweeper review.

Comment thread hermes_cli/web_server.py
return {"profiles": profile_names, "gateway_mode": mode, "gateways": gateways}


# /api/status is polled ~1/s by the desktop app while it waits for the backend

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current Desktop boot now probes /api/health (apps/desktop/electron/backend-health.ts:131-135) and reaches /api/status only for a missing health route. Please reframe this comment around dashboard or other ongoing status consumers.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/profiles Multi-profile isolation, HERMES_HOME scoping labels Jul 30, 2026
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Merged via #76969 — thank you @lost9999. Your commit was cherry-picked, so you remain the author in git history.

One honest note recorded in the salvage PR: since your branch, 23cb26c moved desktop boot readiness to /api/health, so the boot-time GIL-starvation symptom your PR described no longer occurs on current desktops. We merged it anyway because the value is real beyond boot — /api/status is still polled continuously (web sidebar 10s, statusbar 60s, Portal, multi-window), and your TTL cache + collapse lock deduplicates the genuinely expensive topology scan for all of them. The double-checked locking and the fn-identity test-hermeticity trick were both correct as written; nothing was changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants