Skip to content

fix(dashboard-auth): run every blocking IDP provider call off the dashboard event loop - #84891

Open
briandevans wants to merge 7 commits into
NousResearch:mainfrom
briandevans:fix/dashboard-auth-provider-calls-off-loop
Open

briandevans wants to merge 7 commits into
NousResearch:mainfrom
briandevans:fix/dashboard-auth-provider-calls-off-loop

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

Symptom: on a non-loopback bind, the dashboard periodically locks up for
seconds at a time — pages stop loading, the live feed stalls, the terminal
stream freezes — and then everything arrives at once.

Cause: the DashboardAuthProvider protocol is entirely synchronous
(hermes_cli/dashboard_auth/base.py:188-209 — every method is a plain def),
and the shipped providers implement it with blocking network I/O. All nine
call sites in hermes_cli/dashboard_auth/ invoke those methods directly from
async def handlers
, so each one blocks the single uvicorn event loop for
its full duration. This PR dispatches all nine through asyncio.to_thread.

Three provable ways this bites, in descending order of nastiness:

(a) A blocking JWKS re-fetch every 300 seconds, bounded at 30.
verify_session_verify_jwtPyJWKClient.get_signing_key_from_jwt. The
client is constructed at plugins/dashboard_auth/nous/__init__.py:419 and
plugins/dashboard_auth/self_hosted/__init__.py:600 with
lifespan=_JWKS_CACHE_SECONDS (= 300, at nous:132 / self_hosted:127) and
no timeout=, so it inherits PyJWKClient's default timeout = 30. Its
fetch_data is urllib.request.urlopen(r, timeout=self.timeout, ...) — a
blocking synchronous socket call. So every 300 seconds the next request to
traverse the auth gate performs a 30s-bounded blocking fetch to the IDP's JWKS
endpoint on the event loop, and a slow or blackholed IDP freezes the whole
dashboard for up to half a minute. (In steady state, between those re-fetches,
verify_session is a local signature check against a warm cache — this is not
a network round trip on every request.)

(b) A refresh burst at 10s per call, serialised. refresh_session is never
cached; every call is a token-endpoint round trip at
_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0 (nous:135, self_hosted:118). The
access-token cookie's Max-Age tracks the token's own lifetime
(cookies.py:194 passes the provider-supplied access_token_expires_in) while
the refresh-token cookie lives 30 days, so on expiry the browser evicts the AT
and a multi-tab dashboard fires a burst of parallel requests carrying only the
RT. On the event loop those refreshes serialise: the freeze is burst-size ×
round-trip, not one round trip.

(c) Login, callback, password login, logout revoke and OIDC discovery are
unconditionally blocking network calls whenever they run, with no cache in
front. auth_logout is the sharpest of these: the revoke is explicitly
documented as best-effort with failures swallowed, yet on the loop an
unreachable IDP turns an ignorable call into a dashboard-wide stall for the
full timeout, once per registered provider.

Reachability. hermes_cli/web_server.py:17957 sets
app.state.auth_required = should_require_auth(host), true for any
non-loopback bind, and web_server.py:659 registers _dashboard_auth_gate as
@app.middleware("http") — so the gate is on the path of every request to a
publicly-bound dashboard.

In-file contrast. hermes_cli/web_server.py — the same module that
installs this middleware — already applies this rule 51 times, with the
rationale spelled out at web_server.py:2373 ("keep both off the event
loop"). hermes_cli/dashboard_auth/ used it zero times.

This is a pure scheduling change. asyncio.to_thread copies the caller's
context and re-raises the worker's exception in the awaiting frame, so every
try/except arm, exception type, audit call and control-flow branch is
unchanged — ProviderError, RefreshExpiredError, InvalidCodeError,
InvalidCredentialsError and NotImplementedError all still land exactly
where they did.

Related Issue

No linked issue — this is an origination, found by sweeping the synchronous
provider protocol against its async def call sites.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

Sibling-site sweep — complete and closed

git grep -nE "\.(verify_session|refresh_session|start_login|complete_login|complete_password_login|revoke_session)\(" \
  -- . ':!tests/' ':!plugins/'

returns exactly 9 sites in 2 files, and nothing anywhere else in the
repository outside plugins/ (the provider implementations) and tests/.

# Site on main Enclosing Treatment
1 middleware.py:304 provider.verify_session sync _verify_bearer (:290) covered by offloading its only caller at :359
2 middleware.py:424 provider.verify_session inline loop in async gated_auth_middleware direct to_thread
3 middleware.py:564 provider.refresh_session sync _attempt_refresh (:547) covered by offloading its only caller at :457
4 routes.py:205 p.start_login async auth_login direct
5 routes.py:370 p.start_login async auth_native_authorize direct
6 routes.py:470 p.complete_login async auth_callback direct
7 routes.py:710 p.complete_password_login async auth_password_login direct
8 routes.py:768 provider.revoke_session async auth_logout direct
9 routes.py:939 provider.refresh_session async auth_native_refresh direct

Why sites 1 and 3 are offloaded at the helper rather than the inner call.
Both helpers are synchronous and each has exactly one caller
(git grep -n "_verify_bearer\|_attempt_refresh" returns the definition, the
single call, and two docstring mentions — nothing else), and both callers sit
inside async gated_auth_middleware. Offloading the helper moves the entire
provider walk, including its per-provider retries, to a worker in one await,
instead of returning to the loop between providers. It also leaves both helper
bodies byte-identical to main, which keeps this PR out of the regions
three other open PRs are rewriting (see Related / Positioning). audit_log and
_client_ip then run on the worker thread: both are synchronous, _client_ip
only reads already-parsed headers, and audit.py appends under its own lock —
no await is introduced inside either helper.

Both helper docstrings gain an explicit one-line blocking contract, which is
the durable half of the change: the next person to add a caller is told what
the requirement is.

Reasoned exclusion — one site, on purpose

hermes_cli/dashboard_auth/audit.py:92 (with open(path, "a", ...)) is
reached from the same async paths and is the same class of defect (blocking
I/O on the loop), but it has a different root cause and a different remedy: a
sub-millisecond local append with no network and no timeout, versus a 10-second
network round trip. Offloading it would require making audit_log() async at
~30 call sites, several inside synchronous helpers. That is a different change
of a different shape and does not belong in this PR.

Commits

Six atomic commits, one per independently-meaningful seam:

  1. fix(dashboard-auth) — bearer verify (site 1, via :359)
  2. fix(dashboard-auth) — cookie-path verify loop (site 2)
  3. fix(dashboard-auth) — middleware refresh chain (site 3, via :457)
  4. fix(dashboard-auth) — login start ×2 + callback (sites 4, 5, 6)
  5. fix(dashboard-auth) — password login, logout revoke, native refresh (sites 7, 8, 9)
  6. test(dashboard-auth)tests/hermes_cli/test_dashboard_auth_off_loop.py

Each of 1–5 was verified green on the full dashboard_auth suite at its own
SHA (128 passed, 1 skipped at each), and none of them is a no-op — every one
has a test that goes red when only that commit's hunk is reverted (table
below).

How to Test

  1. pytest tests/hermes_cli/test_dashboard_auth_off_loop.py -q → 11 passed.
  2. Revert any single production hunk and re-run — the matching test goes red.
  3. Full dashboard-auth suite: pytest tests/hermes_cli/ -k dashboard_auth -q
    → 139 passed, 1 skipped (128 passed, 1 skipped on main; the 11 new).

Test design

The assertion is structural, not timing-based. A recording provider calls
asyncio.get_running_loop() on entry to each protocol method: RuntimeError
means it is on a worker thread (correct), a live loop means the dashboard's
loop is being blocked (the bug). One assertion per production call site.

One timing test covers the user-visible symptom directly — a provider that
blocks for 2s must not delay a concurrent request to a public, provider-free
route. It is written around the trap that makes this class of test worthless:
TestClient(app) outside a with block builds a fresh anyio portal, and
therefore a fresh event loop, per request
, so two requests can never contend
and such a test passes even against unpatched code. Every client here is
entered as a context manager, pinning one loop for the whole test, exactly as
uvicorn serves the real dashboard.

New file rather than an append to an existing test_dashboard_auth_* module:
it registers its own recording providers and needs its own gate fixture.

Fails-before / passes-after, per reverted hunk

Each row: revert that production hunk alone against the rest of the branch,
run the named test, restore.

Reverted hunk Test With hunk reverted On the branch
middleware.py bearer verify (:359) test_bearer_verify_runs_off_the_loop, test_a_slow_provider_does_not_stall_a_concurrent_request 2 failed 2 passed
middleware.py cookie verify (:424) test_cookie_verify_runs_off_the_loop 1 failed 1 passed
middleware.py refresh chain (:457) test_middleware_refresh_runs_off_the_loop 1 failed 1 passed
routes.py auth_login (:205) test_auth_login_start_runs_off_the_loop 1 failed 1 passed
routes.py auth_native_authorize (:370) test_native_authorize_start_runs_off_the_loop 1 failed 1 passed
routes.py auth_callback (:470) test_auth_callback_complete_runs_off_the_loop 1 failed 1 passed
routes.py auth_password_login (:710) test_password_login_runs_off_the_loop 1 failed 1 passed
routes.py auth_logout (:768) test_logout_revoke_runs_off_the_loop 1 failed 1 passed
routes.py auth_native_refresh (:939) test_native_refresh_runs_off_the_loop 1 failed 1 passed

test_provider_errors_still_surface_unchanged is deliberately not in that
table: it is a behaviour-preservation guard and passes both before and after.
It is here so a later refactor cannot swap the offload for something that drops
the provider's exception instead of re-raising it in the awaiting frame.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — ran the full dashboard_auth suite (139 passed, 1 skipped) plus per-commit runs, not the whole tree; relying on CI for that
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 26.4.1 (arm64), Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — the _verify_bearer and _attempt_refresh docstrings now state their blocking contract
  • N/A — no config keys added or changed
  • N/A — no architecture or workflow change
  • I've considered cross-platform impact — asyncio.to_thread is stdlib (3.9+) and platform-independent; no platform-specific code paths touched
  • N/A — no tool descriptions or schemas changed

Related / Positioning

Ten open PRs touch these two files. None is a superset of this one, and only
one offloads anything.
Line-level overlaps, by number and anchor (rival hunk
line numbers are against their own bases):

PR Author Touches Relationship
#71548 Doud-FR routes.py @@ -895,46 +1099,35 @@ async def auth_native_refresh The one rival that offloads. Its concern is per-token single-flight coalescing, but the rewrite also moves the provider walk off the loop via await run_in_threadpool(_refresh_native_session_sync, ...). That covers site 9 of our nine, and deletes the exact line we change. It does not touch the other eight.
#72743 rohitsabu middleware.py @@ -454,7 Direct overlap with our site 3 line: rewrites refreshed = _attempt_refresh(_attempt_refresh_with_replay_grace(. Concern is desktop session persistence; the new wrapper is still synchronous, so nothing moves off the loop.
#55717 liuhao1024 middleware.py @@ -350,7, @@ -427,35 +450,75 Overlaps our site 3 line (_attempt_refresh(await _attempt_refresh() and rewrites the helper body. Concern is RT-reuse coalescing. Making the helper async def does not take the blocking call off the loop — the inner provider.refresh_session(...) stays synchronous inside it. Cold since 07-15 and already conflicts with main.
#40847 Dusk1e middleware.py @@ -341,16, @@ -361,7 (inside _attempt_refresh) Don't abort the refresh chain on a decline. We leave that body byte-identical.
#83536 benbarclay routes.py @@ -530,7, @@ -551,7, @@ -766,7 SameSite/Secure PKCE cookies. The @@ -766,7 hunk is in auth_logout but covers its tail (clear_pkce_cookie), not our revoke_session line. Same function, disjoint lines.
#84065 Kailigithub routes.py @@ -20, @@ -396,12 PKCE cookie URL-encoding in auth_callback; disjoint from our :470.
#61305, #67587, #68245 middleware.py, inside/near gated_auth_middleware Insertions at different anchors (auth hardening, /api/config/schema gating, native loopback login). None offloads.
#64254, #83294, #77207 plugins/dashboard_auth/** only We do not touch plugins/. Disjoint.

Text searches for the offload concern itself — dashboard_auth to_thread,
asyncio.to_thread dashboard, run_in_executor dashboard_auth,
run_in_threadpool dashboard_auth, blocking event loop dashboard auth
return zero open PRs.

Precedent for this exact shape (cited as precedent for the pattern, not as
anyone requesting this change): #83951, "fix(gateway): offload all blocking
atomic_json_write calls from async paths"
, merged 2026-08-11, uses the same
await asyncio.to_thread(fn, ...) at-the-call-site idiom. The class has a long
merge history: #55159, #53603, #56212 merged by @teknium1; #76972, #74048,
#51889, #51890, #48561 by @kshitijk4poor; #67283, #65893 by @OutThisLife.

…event loop

`gated_auth_middleware` is an `async def` running on the single uvicorn
event loop, but `_verify_bearer` is synchronous and walks every registered
session provider calling `verify_session`. The shipped providers verify a
JWT against a `PyJWKClient` built with `lifespan=300` and no `timeout=`
(`plugins/dashboard_auth/nous/__init__.py:419`,
`plugins/dashboard_auth/self_hosted/__init__.py:600`), so it inherits
PyJWKClient's 30s default and `fetch_data` does a blocking
`urllib.request.urlopen`. Every 300 seconds the next bearer request to
traverse the gate performs that fetch inline on the loop, and a slow or
blackholed IDP stalls the entire dashboard — every other request, the live
feed and the terminal stream — for up to 30 seconds.

Offload at the helper rather than at the inner `verify_session` call so the
whole provider loop, including its per-provider retries, moves to a worker
in one `await` instead of ping-ponging the loop once per provider. The
helper body is unchanged. `asyncio.to_thread` copies the caller's context
and re-raises the worker's exception in the awaiting frame, so the
`ProviderError` arm and the 503 it returns are untouched; this is a pure
scheduling change.

`hermes_cli/web_server.py`, the module that installs this middleware, already
applies the same rule 51 times (rationale at `web_server.py:2373`);
`hermes_cli/dashboard_auth/` had zero uses.
… loop

The cookie branch of `gated_auth_middleware` runs its own verify loop
inline, calling the synchronous `provider.verify_session` for each
registered session provider directly on the event loop. This is the hot
path: it executes on every gated request that presents an access-token
cookie.

In steady state the JWKS cache is warm and the verify is a local signature
check, so most requests are cheap. But the cache expires every 300 seconds
(`_JWKS_CACHE_SECONDS`), and the request unlucky enough to land after
expiry pays a blocking `urlopen` to the IDP's JWKS endpoint, bounded only
by PyJWKClient's 30s default. Stacked providers multiply this: each
provider that cannot be reached raises only after its own timeout, and the
loop tries the next one, so the gate can hold the event loop for a
multiple of that ceiling while deciding a single request.

Offload the individual `verify_session` call rather than the whole loop:
the loop body between calls audits and logs against the request, and
keeping that on the loop leaves the surrounding control flow — the
`ProviderError` arm, the `unreachable_provider` bookkeeping and the 503 —
byte-for-byte identical.
`_attempt_refresh` is the last synchronous provider call left in
`gated_auth_middleware`, and it is the worst of the three. Unlike verify,
refresh is never served from a cache: every call is a token-endpoint round
trip at `_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0`
(`plugins/dashboard_auth/nous/__init__.py:135`,
`plugins/dashboard_auth/self_hosted/__init__.py:118`), and the helper walks
every registered session provider until one accepts the refresh token.

The reachability is routine rather than exceptional. The access-token
cookie's Max-Age tracks the token's own lifetime, so the browser evicts it
the moment the token lapses while the refresh-token cookie lives for 30
days. A dashboard with several tabs open therefore fires a burst of
parallel requests that each carry only the RT — and on the event loop those
refreshes serialise, so the freeze is the burst size times the round trip,
not one round trip.

Offload at the helper, not at the inner `refresh_session`: both the helper
and its single caller sit in `gated_auth_middleware`, so one `await` moves
the entire provider walk to a worker instead of returning to the loop
between providers. The helper body is unchanged, and the audit trail is
unaffected — `audit_log` is synchronous and appends under its own lock, and
`_client_ip` only reads already-parsed headers, so neither needs the loop.
…e event loop

`auth_login`, `auth_native_authorize` and `auth_callback` are all
`async def` handlers that call synchronous provider methods directly.
Unlike the gate's verify path, none of these has a cache in front of it —
`start_login` may perform OIDC discovery and `complete_login` is
unconditionally a token-endpoint round trip at
`_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0`, with the self-hosted provider also
fetching the discovery document (`plugins/dashboard_auth/self_hosted`
`__init__.py:524`). Every one of these is a guaranteed network call on the
event loop whenever the route runs.

The blast radius is the whole dashboard, not the person signing in: while a
login round trip is in flight the loop cannot serve any other request, so
one user's slow IDP hop stalls every already-authenticated session on the
instance.

`_redirect_uri(request)` stays on the loop and its result is passed in as
an argument — it is a pure header read, so there is nothing to gain from
moving it, and evaluating it eagerly keeps the argument list identical to
what the provider saw before.
…fresh off the event loop

The last three synchronous provider calls in the router, all reached from
`async def` handlers.

`complete_password_login` verifies credentials against the provider's
backing store; the route already rate-limits it per IP, which is only
meaningful if the handler can keep serving requests while one credential
check is outstanding. On the event loop the rate limiter and the freeze
work against each other — a burst of login attempts blocks the dashboard
for the duration of the attempts it is trying to throttle.

`auth_logout` is the clearest case: the revoke is explicitly documented as
best-effort, and failures are logged and never raised. Logging out is not
supposed to be able to hurt anyone, but on the event loop an unreachable
IDP makes an ignorable revoke stall the whole instance for the full
timeout, once per registered provider, before the user is redirected.

`auth_native_refresh` is the desktop's rotation endpoint and mirrors the
middleware's `_attempt_refresh` provider stacking, with the same
unconditional token-endpoint round trip per provider.

Each `to_thread` wraps only the provider call itself; the surrounding
`try`/`except` arms, audit calls and control flow are unchanged, and the
worker's exception is re-raised in the awaiting frame so
`InvalidCredentialsError`, `NotImplementedError`, `RefreshExpiredError`
and `ProviderError` still land where they did.
…ker thread

Nine production call sites invoke the synchronous DashboardAuthProvider
protocol from `async def` handlers. This locks all nine to a worker thread
so a future edit cannot quietly put one back on the event loop.

The assertion is structural rather than timing-based: a recording provider
calls `asyncio.get_running_loop()` at entry. A RuntimeError means it is on a
worker thread (correct); a live loop means the dashboard's loop is being
blocked. Each test reverts red with only its own production hunk undone.

One timing test is included for the symptom itself — a provider that blocks
for two seconds must not delay a concurrent request to a public,
provider-free route. It is written around the trap that makes this class of
test worthless: `TestClient(app)` outside a `with` block builds a fresh
anyio portal, and therefore a fresh event loop, for every request, so two
requests can never contend and the test passes even against unpatched code.
Every client here is entered as a context manager, pinning one loop for the
whole test exactly as uvicorn serves the dashboard.

`test_provider_errors_still_surface_unchanged` is a behaviour-preservation
guard, not regression coverage: it passes both before and after, and exists
so a later refactor cannot swap the offload for something that drops the
provider's exception instead of re-raising it in the awaiting frame.

New file rather than an append to an existing `test_dashboard_auth_*`
module, because it registers its own recording providers and needs its own
gate fixture.
Copilot AI lite review requested due to automatic review settings August 12, 2026 23:56

Copilot AI 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.

Pull request overview

This PR fixes dashboard lockups caused by synchronous DashboardAuthProvider implementations performing blocking network I/O when invoked directly from async def FastAPI handlers. It routes all dashboard-auth provider calls that can block (verify/refresh/login/logout flows) off the uvicorn event loop using asyncio.to_thread, preserving existing exception and control-flow behavior.

Changes:

  • Offload provider calls in dashboard_auth routes (start_login, complete_login, complete_password_login, refresh_session, revoke_session) via await asyncio.to_thread(...).
  • Offload provider calls in the auth middleware (verify_session and the refresh chain) via asyncio.to_thread, with docstrings explicitly documenting the blocking contract for sync helpers.
  • Add a dedicated regression test module that asserts each provider call site runs off-loop (plus a concurrency symptom test to ensure public routes aren’t stalled by a slow provider).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
hermes_cli/dashboard_auth/routes.py Offloads provider login/refresh/logout calls from async route handlers via asyncio.to_thread.
hermes_cli/dashboard_auth/middleware.py Offloads bearer verify, cookie verify loop, and refresh chain from the async middleware; documents blocking sync helpers.
tests/hermes_cli/test_dashboard_auth_off_loop.py Adds off-loop contract tests per call site and a concurrency regression to catch event-loop stalls.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@alt-glitch alt-glitch added type/perf Performance improvement or optimization comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/auth Authentication, OAuth, credential pools P3 Low — cosmetic, nice to have sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 13, 2026
@teknium1 teknium1 closed this Aug 13, 2026
@teknium1 teknium1 reopened this Aug 13, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(dashboard-auth): run every blocking IDP provider call off the dashboard event loop

  • Comprehensive and well-tested: every provider call site is offloaded, the loop-recording provider pins the invariant directly (no timing-based flakiness), and entering TestClient as a context manager correctly avoids the per-request-loop trap that would let a concurrency test pass against unpatched code. The exception-surfacing guard (test_provider_errors_still_surface_unchanged) is a nice behaviour-preservation net.
  • Shared default executor: asyncio.to_thread uses the process-wide default ThreadPoolExecutor (min(32, cpu+4)). A burst of concurrent dashboard requests against slow IDPs (10s token-endpoint timeouts) can saturate all workers and queue unrelated to_thread users across the process. A dedicated bounded executor for provider I/O would isolate the blast radius — optional, but worth considering for a gateway that multiplexes many sessions.
  • Request object crossing threads: _verify_bearer(request, ...) and _attempt_refresh(request, ...) pass the Starlette Request into the worker thread. Sync cookie/header access is fine there, but confirm no provider path touches async-only request parts (e.g. await request.body() / streaming) inside the offloaded function — that would fail in the thread with a "no running event loop" style error.
  • The concurrency test's 1s budget vs 2s delay has good margin; a heavily loaded CI box could theoretically push a correct run past 1s, but the risk is low.

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

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P3 Low — cosmetic, nice to have sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants