Skip to content

fix(network): add timeouts to critical-path fetches (#445)#448

Merged
iamtoruk merged 1 commit into
mainfrom
fix/network-fetch-timeouts
Jun 6, 2026
Merged

fix(network): add timeouts to critical-path fetches (#445)#448
iamtoruk merged 1 commit into
mainfrom
fix/network-fetch-timeouts

Conversation

@iamtoruk
Copy link
Copy Markdown
Member

@iamtoruk iamtoruk commented Jun 6, 2026

Fixes #445.

Problem

The menubar hangs forever on the loading spinner (~once a day, typically the first open after the Mac sleeps overnight). Root cause, as diagnosed in the issue:

  • Every CLI command awaits loadPricing(), and the macOS menubar shells out to the CLI and blocks on Process.waitUntilExit.
  • Once the 24h pricing cache (litellm-pricing.json) expires, loadPricing() is forced to call fetchAndCachePricing()fetch(LITELLM_URL).
  • That fetch() had no timeout. A half-open network after wake-from-sleep (accepts the connection, never replies) makes it hang forever. The existing try/catch only handles rejections; a hang never resolves or rejects, so loadPricing() awaits indefinitely and the CLI never prints or exits → the menubar's waitUntilExit blocks until relaunch.

Fix

A shared fetchWithTimeout helper (src/fetch-utils.ts, 8s default via AbortSignal.timeout), applied to the two daily-critical-path fetches:

  • fetchAndCachePricing (models.ts) — the proven culprit, on every invocation
  • fetchRate (currency.ts) — same daily-refresh path for non-USD users

On timeout the request rejects with a TimeoutError, which the callers' existing catch already handles — pricing falls back to the bundled snapshot, currency to the cached/USD rate. So the timeout is safe: no behavior change on a healthy network, graceful degradation on a stalled one.

Scope: the always-on critical path. The release-installer fetches in menubar-installer.ts also lack timeouts but only run during install/update (not the daily hang path) — left for a separate change.

Proof (reproduced on main)

Stale pricing cache + the LiteLLM host black-holed (accepts, never replies):

Result
main (no timeout) hangs the full 20s wall-clock, killed (exit 124), no output
with this fix aborts at ~8s, falls back to the snapshot, renders the dashboard (exit 0)

Testing

  • New tests/fetch-utils.test.ts — aborts on a hanging server within the window, passes through a responsive one, and still aborts when the caller also supplies a signal.
  • models.test.ts (100) + currency-rounding.test.ts (10) + fetch-utils.test.ts (3) all pass. tsc --noEmit clean.

Follow-up (not in this PR)

The reporter's second finding — the Swift menubar never respawns a child after a hung load (↻ refresh is a no-op) — is a separate app-side recovery defect worth its own change.

The pricing fetch (loadPricing -> fetchAndCachePricing) runs on every CLI
invocation, and the macOS menubar shells out to the CLI and blocks on its
exit. fetch() had no timeout, so a half-open network after wake-from-sleep
made it hang forever once the 24h pricing cache expired — wedging the
menubar on its loading spinner until relaunch.

Add a shared fetchWithTimeout helper (8s default, AbortSignal.timeout) and
apply it to the two daily-critical-path fetches: pricing (models.ts) and
the currency rate (currency.ts). On timeout the existing catch falls back
to the bundled price snapshot / cached or USD rate.

Reproduced on main (stale cache + black-holed host -> hangs indefinitely);
with the timeout the same scenario aborts in 8s and renders via fallback.
@iamtoruk iamtoruk merged commit f57ad64 into main Jun 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Menubar hangs forever on "Loading 7 Days…" after ~24h (pricing fetch() has no timeout)

1 participant