fix(dashboard): stop a hung usage fetch parking the credit pill forever - #2498
Conversation
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've read the diff, both AUTOSDE rule snapshots, and the surrounding code in The change wraps the body of Tracing the semantics: I checked for:
No blocking or reachable semantic defect on the changed lines. No findings. [OPUS-REVIEWED] c8c50d2 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Bounding the whole refresh is the right invariant — root-cause fix, lands in the existing failure handler, and abandoned wedged work sits in the pool built to cap exactly that. [DESIGN-REVIEWED] c8c50d2 |
…er (kirodotdev#2498) Co-authored-by: Junfeng Qiu <junfume@Junfengs-Mac-mini-2.local>
Fixes #2364.
The bug
_fetch_usage_bg()is gated by a module-level_usage_fetchingflag that is setbefore the work and cleared only in the function's
finally. Thatfinallyiscorrect as far as it goes — it does run on
CancelledError, so the cancellationmechanism named in the report is not the cause.
What it cannot survive is a hang, because the refresh has no overall deadline.
fetch_usage_limitsis awaited viarun_in_executor(subprocess_executor(), ...)with no
wait_for, and theurlopen(timeout=15)inside it does not covergetaddrinfo— nor the wait for a free worker in a bounded pool. If that callblocks, the coroutine never reaches its
finally,_usage_fetchingstaysTruefor the process lifetime, and every subsequent refresh returns immediately at
the guard.
_usage_cachetherefore stays{}forever. The frontend cannot recover on itsown: the
useQueryatApp.tsxreturnsnullunlesscredits_planis finite oravailable === false, and!kiroUsagerenders the spinner — so a never-populatedcache is indistinguishable from a warming one, and the pill reads
"Checking usage..." indefinitely with nothing logged.
The fix
One ceiling over the whole refresh. Every await inside is either already bounded
(
whoami≤30s, the billed scrape ≤60s) or an executor call that can blockindefinitely; bounding the total is the invariant that actually matters, and it
cannot be defeated by a future await being added inside.
A timeout lands in the existing
except asyncio.TimeoutErrorhandler, whichcalls
_cache_transient_failure()— keeping the last good value as stale, orcaching
{"available": False}when there is nothing to show. Either way the pillresolves instead of spinning.
asyncio.timeout()would express this without restructuring, but it needs 3.11and
requires-pythonis>=3.10, so the body is lifted into a nested coroutinefor
wait_for. Its indentation is unchanged, so the real diff is 22 lines —git diff -wandgit diffagree.Verification
Two tests in the new
TestFetchUsageDeadline:test_hung_api_read_still_clears_the_guard—fetch_usage_limitsblocks on anevent; asserts
_fetch_usage_bg()returns,_usage_fetching is False, and thecache resolves to
available: False.test_refresh_after_a_hang_can_still_succeed— proves the guard is not merelycleared but that a later refresh populates the cache normally.
Both patch the deadline with
raising=Falseso they do not depend on the newconstant existing, which is what makes them exercise the real hang on unfixed
code rather than erroring on a missing attribute.
Causal proof — with the tests in place, reverting only
src/kiro_crew/dashboard/handlers/sessions.pytoorigin/main:With the fix restored:
2 passed in 0.70s.Local gates: every test file importing this module —
test_session_usage.py,test_usage.py,test_sessions_health_cache.py,test_session_keepalive.py,test_computer_use_api.py,test_dashboard_sessions_memory.pyand 8 more —737 passed, 0 failures. Plus
test_kiro_usage_api.pyandtest_kiro_spawn_readiness_gate.py. isort, flake8 clean; mypy clean onsessions.py(mypy 1.14.1, matching thepyproject.tomlpin).Scope notes
slow refresh still completes, well below the 600s refresh interval so a wedged
one always clears before the next attempt.
left out — with the deadline in place there is no path from
_usage_fetching = Trueto a missedfinally, so it would be redundant machinery.KIRO_API_KEYappears nowhere in the repo, and the readiness probe's env allowlist omits it,
which would 503 this endpoint). That is a separate question — it would also
break
/api/models, regenerate and rewind, which the reporter did not observe— so it is deliberately not addressed here. This change fixes the wedge that
makes the pill spin with no error at all.
undefined(fetch rejected) should render differently fromnull.