fix(daily-market-brief): cap LLM summarizer + total build budget so the panel can't hang forever#3718
Conversation
…he panel can't hang forever
## Symptom
PRO users were reporting the Daily Market Brief panel stuck on "Building
daily market brief..." indefinitely after a cache miss. The try/catch
around the summarizer was decorative — it only handles REJECTIONS, and a
hung upstream (LLM provider slow, Vercel cold-start + slow network) leaves
the awaited promise pending forever, never reaching catch.
## Verified root cause (no extrapolation this time)
Traced the chain top-to-bottom for an enforced timeout:
loadDailyMarketBrief data-loader.ts:1635
→ buildDailyMarketBrief daily-market-brief.ts:381
→ generateSummary summarization.ts:197
→ summaryResultBreaker.execute no timeoutMs option (line 20)
→ tryApiProvider summarization.ts:76
→ summaryBreaker.execute no timeoutMs option
→ newsClient.summarizeArticle(...) accepts options.signal
but no caller passes one
`vercel.json` has no `maxDuration` override; the default function timeout
is whatever Vercel applies, but the CLIENT awaits TCP keepalive, so from
the browser's perspective the fetch can be effectively forever.
## Fix
`src/utils/with-timeout.ts` — new utility. Races a promise with a
deadline; the source promise is not cancelled (JS has no general cancel)
but the await unblocks via TimeoutError so caller fallback paths fire.
`src/services/daily-market-brief.ts:432` — wrap the
`summaryProvider(...)` call in `withTimeout(..., 45_000, 'daily-brief-
summary')`. On timeout the EXISTING catch (line 444) falls back to the
rules-based summary (`buildRuleSummary`) — already pre-computed at the
top of the function, so this costs nothing extra. Added
`summarizerTimeoutMs?` to `BuildDailyMarketBriefOptions` so tests can
exercise the fallback in ~30ms without waiting 45s.
`src/app/data-loader.ts:1644` — wrap `getCachedDailyMarketBrief(...)` in
a 3s `withTimeout` + `.catch(() => null)`. A hung persistent-cache layer
can't keep the panel on its default Loading state — fall through to
"build from scratch" instead.
`src/app/data-loader.ts:1667` — wrap `buildDailyMarketBrief(...)` in a
60s `withTimeout` (outer budget). Inner summarizer has its own 45s cap;
this catches the dynamic-import path (`getDefaultSummarizer()` import
hanging on a slow chunk fetch), `_collectRegimeContext` /
`_collectYieldCurveContext` / `_collectSectorContext` exotic hangs
(though they're already in `Promise.allSettled`), etc. The existing
catch (line 1693) serves the cached version or shows an error.
## Test plan
- `tests/with-timeout.test.mjs` — 7 unit tests: resolve-first, reject-
first, hang→TimeoutError, timer cleanup (proves a 5s-budget test
exits at 5ms not 5s), onTimeout-once, onTimeout-not-called-on-success,
onTimeout-throw-doesn't-hijack.
- `tests/daily-market-brief.test.mts` — new regression test
`a hanging summarizer must not stall the brief` reproduces the prod
shape with an injected `() => new Promise(() => {})` (pending forever)
and asserts the brief returns within the timeout with
`provider: 'rules'` and `fallback: true`. Test elapsed ~33ms.
Local gate: typecheck PASS, biome on touched files PASS (4 pre-existing
warnings on data-loader.ts unrelated complexity scores, present on main
too), lint:api-contract PASS.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR fixes indefinite panel hangs in the Daily Market Brief by introducing a
Confidence Score: 3/5The core LLM summarizer fix is correct and well-tested, but the catch block recovery path in data-loader.ts calls getCachedDailyMarketBrief without any timeout guard, leaving a hang path open in the exact error-recovery branch this PR creates. The unguarded getCachedDailyMarketBrief in the outer-timeout catch block means a failure mode involving two concurrent degradations — build hang plus IndexedDB slowness in recovery — would still leave the panel stuck. The fix is otherwise sound with good tests covering the primary summarizer-hang path. src/app/data-loader.ts — specifically the catch block at line 1715 where the recovery cache read lacks a timeout guard. Important Files Changed
Sequence DiagramsequenceDiagram
participant Panel as DailyMarketBrief Panel
participant DL as DataLoaderManager
participant IDB as IndexedDB Cache
participant Build as buildDailyMarketBrief
participant LLM as LLM Summarizer
participant Rules as buildRuleSummary
DL->>+IDB: getCachedDailyMarketBrief(tz)
Note over DL,IDB: withTimeout 3s
alt cache hit and fresh
IDB-->>DL: cached brief
DL->>Panel: renderBrief(cached)
DL->>DL: return
else timeout or stale
IDB--xDL: TimeoutError or null
DL->>Panel: showLoading
DL->>+Build: buildDailyMarketBrief
Note over DL,Build: withTimeout 60s outer budget
Build->>Rules: buildRuleSummary pre-computed
Build->>+LLM: summaryProvider(headlines)
Note over Build,LLM: withTimeout 45s inner budget
alt LLM responds
LLM-->>Build: SummarizationResult
Build-->>DL: brief provider llm
else LLM hangs or timeout
LLM--xBuild: TimeoutError
Build->>Build: catch use rules summary
Build-->>DL: brief provider rules fallback true
end
DL->>IDB: cacheDailyMarketBrief
DL->>Panel: renderBrief live
end
alt outer 60s timeout fires
Build--xDL: TimeoutError
DL->>IDB: getCachedDailyMarketBrief unbounded gap
IDB-->>DL: cached or null
DL->>Panel: renderBrief cached or showError
end
|
## P2 (Greptile, valid) The original PR wrapped the upfront cache read in withTimeout but I missed the recovery cache read inside the catch block at `data-loader.ts:1715` — same hang risk in the exact path the fix creates. If the outer 60s build budget fires AND IndexedDB is also degraded, the recovery read keeps the panel stuck. Wrap the recovery `getCachedDailyMarketBrief(...)` in the same 3s `withTimeout` (label `'daily-brief-cache-read-recovery'` so logs distinguish the two cache reads) + the existing `.catch(() => null)` absorbs both the TimeoutError and any persistent-cache failure into the null-result branch that the showError fallback already handles. ## Nits - Renamed `tests/with-timeout.test.mjs` → `.mts` to match the project convention for tests that directly import `.ts` sources (see `daily-market-brief.test.mts`). Functionally identical; lines up with the rest of the test directory. - Added the missing test case Greptile flagged: assert `onTimeout` is NOT invoked when the source REJECTS first (the prior set only covered the resolve-first path). Without `.finally(clearTimeout)` the timer would still fire after `Promise.race` already settled — onTimeout would run as a phantom side-effect after the caller had already moved on. The test waits 80ms past the 50ms budget to prove the timer was cleared. All 8 `withTimeout` cases pass; the daily-market-brief regression test still falls back within budget; typecheck + biome clean.
…d 2) Both findings valid — and embarrassing, because they're the same hang class this PR was opened to fix. The earlier passes wrapped the LLM summarizer call (the obvious suspect) but missed three more unbounded awaits in the same function. ## P1 #1 — Context collectors hung before the 60s envelope started `_collectRegimeContext` calls `client.getFearGreedIndex({})` and `_collectYieldCurveContext` calls `client.getFredSeriesBatch(...)` — both unbounded RPCs. `Promise.allSettled` only converts REJECTIONS into status:rejected; it waits forever for pending-forever promises. My withTimeout envelope was wrapped around `buildDailyMarketBrief(...)` AFTER the allSettled line, so a hung context collector kept the panel on "Building daily market brief..." forever — exactly the symptom this PR was supposed to fix. Wrap each RPC-using collector in `withTimeout(..., 8_000)` with its own label. 8s is generous for an RPC and leaves >36s of the outer 60s budget for the actual LLM call. `_collectSectorContext` is sync (only reads hydrated data) so it needs no wrap; allSettled accepts non- promises directly. ## P1 #2 — Cache write blocked render `await cacheDailyMarketBrief(brief)` ran BEFORE the render call, so a hung IndexedDB / Tauri-Store write meant the user never saw the finished brief even though it was sitting in memory ready to display. The build budget proved nothing by itself. Render first, persist after. Cache write is now fire-and-forget with its own 5s budget (`void withTimeout(...).catch(...)`) — a hung backend becomes "no warmup for tomorrow's load" instead of "panel stuck on Building forever." ## On the catch-path cache read The reviewer also flagged the catch-block `getCachedDailyMarketBrief` call as unbounded; my previous commit (Greptile P2 fix) already wrapped that one with `withTimeout(..., 3_000, 'daily-brief-cache-read-recovery')`. Verified still present in this round. Local gate: typecheck PASS, biome on data-loader.ts PASS (4 pre- existing complexity warnings unrelated to this diff), with-timeout suite 8/8, daily-market-brief regression still passes.
|
Closing both Greptile P2 threads — Greptile reviewed only commit
Both verified live on main ( |
Symptom
PRO users were reporting the Daily Market Brief panel stuck on "Building daily market brief..." indefinitely after a cache miss. The try/catch around the summarizer was decorative — it only handles rejections, and a hung upstream (LLM provider slow, Vercel cold-start + slow network, transient TCP keepalive holding the socket open) leaves the awaited promise pending forever, never reaching catch.
I called myself out earlier in the session for claiming "it's a timeout issue" without proof. This PR has the evidence.
Verified root cause
Traced the await chain top-to-bottom looking for ANY enforced timeout:
loadDailyMarketBrief(panel entry)data-loader.ts:1635buildDailyMarketBriefdaily-market-brief.ts:381summaryProvider(...)awaitdaily-market-brief.ts:432generateSummarysummarization.ts:197summaryResultBreaker.executesummarization.ts:214CircuitBreakerOptionshas notimeoutMsfield (seecircuit-breaker.ts:20)tryApiProvider→summaryBreaker.executesummarization.ts:86newsClient.summarizeArticle(...)service_client.ts:148options.signalbut no caller passes onevercel.jsonmaxDurationoverrideThe client awaits TCP keepalive — from the browser's perspective the fetch can be effectively forever.
Fix
src/utils/with-timeout.ts— new utility. Races a promise with a deadline; rejects with aTimeoutErrorwhose.labelmatches the caller's label. Source promise is not cancelled (JS has no general cancel), but the await unblocks and the caller's existing fallback path can fire. If true cancellation is needed (release the socket, stop the LLM cost meter), pair with anAbortController— `withTimeout` is the budget, `AbortSignal` is the cancellation.src/services/daily-market-brief.ts:432— wrapsummaryProvider(...)in `withTimeout(..., 45_000, 'daily-brief-summary')`. On timeout the existing catch (line 444) falls back to the rules-based summary (`buildRuleSummary`), which is already pre-computed at the top of the function — costs nothing extra. Added `summarizerTimeoutMs?` to `BuildDailyMarketBriefOptions` so tests can exercise the fallback in ~30ms without waiting 45s.`src/app/data-loader.ts:1644` — wrap `getCachedDailyMarketBrief(...)` in a 3s `withTimeout` + `.catch(() => null)`. A hung persistent-cache layer can't keep the panel on its default Loading state — fall through to "build from scratch" instead.
`src/app/data-loader.ts:1667` — wrap `buildDailyMarketBrief(...)` in a 60s outer `withTimeout`. The inner summarizer has its own 45s cap; this outer budget catches exotic hangs the inner one wouldn't (dynamic import `getDefaultSummarizer()` hanging on a slow chunk fetch, etc.). The existing catch (line 1693) serves the cached version or shows an error.
Test plan
`tests/with-timeout.test.mjs` — 7 unit tests:
`tests/daily-market-brief.test.mts` — new regression test:
Reproduces the actual prod shape with an injected `() => new Promise(() => {})` (pending forever) and asserts:
Local gate: typecheck PASS, biome on touched files PASS (4 pre-existing complexity warnings on `data-loader.ts` unrelated to this diff; present on bare main too), `lint:api-contract` PASS.