Skip to content

fix(daily-market-brief): cap LLM summarizer + total build budget so the panel can't hang forever#3718

Merged
koala73 merged 3 commits into
mainfrom
fix/daily-market-brief-timeouts
May 16, 2026
Merged

fix(daily-market-brief): cap LLM summarizer + total build budget so the panel can't hang forever#3718
koala73 merged 3 commits into
mainfrom
fix/daily-market-brief-timeouts

Conversation

@koala73

@koala73 koala73 commented May 16, 2026

Copy link
Copy Markdown
Owner

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:

Layer File / line Has a timeout?
loadDailyMarketBrief (panel entry) data-loader.ts:1635 no
buildDailyMarketBrief daily-market-brief.ts:381 no
summaryProvider(...) await daily-market-brief.ts:432 no ← hang site
generateSummary summarization.ts:197 no
summaryResultBreaker.execute summarization.ts:214 no — CircuitBreakerOptions has no timeoutMs field (see circuit-breaker.ts:20)
tryApiProvidersummaryBreaker.execute summarization.ts:86 no — same breaker, no timeout
newsClient.summarizeArticle(...) generated client service_client.ts:148 accepts options.signal but no caller passes one
vercel.json repo root no maxDuration override

The 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 a TimeoutError whose .label matches 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 an AbortController — `withTimeout` is the budget, `AbortSignal` is the cancellation.

src/services/daily-market-brief.ts:432 — wrap summaryProvider(...) 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:

  • resolve-first / reject-first / hang→TimeoutError
  • timer cleanup (the smoking-gun: a 5s-budget test that exits at 5ms not 5s)
  • onTimeout called exactly once
  • onTimeout not called when source resolves
  • onTimeout-throw doesn't hijack the reject path

`tests/daily-market-brief.test.mts` — new regression test:

`a hanging summarizer must not stall the brief — falls back to rules within the timeout`

Reproduces the actual prod shape with an injected `() => new Promise(() => {})` (pending forever) and asserts:

  • `brief.available === true`
  • `brief.provider === 'rules'`
  • `brief.fallback === true`
  • Elapsed under 5s (proves the 30ms test budget actually fired)

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.

…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.
@vercel

vercel Bot commented May 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 16, 2026 6:11am

Request Review

@greptile-apps

greptile-apps Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes indefinite panel hangs in the Daily Market Brief by introducing a withTimeout utility and applying it at three strategic points in the call chain — the IndexedDB cache read (3s), the LLM summarizer (45s), and the overall build (60s). The approach is well-reasoned and the core fix is correct; the inner 45s timeout is paired with the existing rules-based fallback so users always get a usable brief.

  • src/utils/with-timeout.ts: New generic promise-racing utility with a typed TimeoutError, clean timer cleanup via .finally(), and an onTimeout hook. Implementation is correct.
  • src/services/daily-market-brief.ts: LLM summarizer call is now bounded; summarizerTimeoutMs option enables fast regression testing without waiting the full 45s prod budget.
  • src/app/data-loader.ts: Cache read and full build are both guarded, but the catch block's recovery getCachedDailyMarketBrief call (line 1715) remains unbounded — if that read hangs in the error path, the panel can still be stuck despite the new guards.

Confidence Score: 3/5

The 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

Filename Overview
src/utils/with-timeout.ts New utility that races a promise against a setTimeout deadline using Promise.race, clears the timer in .finally(), and rejects with a typed TimeoutError on expiry. Implementation is correct.
src/services/daily-market-brief.ts Wraps the LLM summaryProvider call in withTimeout with a 45s budget; on timeout the existing catch falls back to the pre-computed rules-based summary. Logic and fallback path are sound.
src/app/data-loader.ts Adds a 3s timeout on the upfront IndexedDB cache read and a 60s wall-clock budget on buildDailyMarketBrief, but the catch block (line 1715) fires an unbounded getCachedDailyMarketBrief call, leaving the panel potentially stuck if IndexedDB is also degraded at that point.
tests/daily-market-brief.test.mts Adds regression test that injects a pending-forever summarizer with a 30ms budget and asserts the brief falls back to rules-based output within 5s, directly reproducing the prod symptom.
tests/with-timeout.test.mjs Covers 7 cases including timer cleanup and onTimeout behaviour, but misses the early-rejection case for onTimeout. Uses .mjs extension while importing a .ts source, inconsistent with project convention.
src/utils/index.ts Re-exports withTimeout and TimeoutError from the new module. Change is trivial and correct.

Sequence Diagram

sequenceDiagram
    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
Loading

Comments Outside Diff (1)

  1. src/app/data-loader.ts, line 1715 (link)

    P1 Unguarded cache read inside the timeout-triggered catch path

    The withTimeout on line 1682 adds a 60s wall-clock budget to protect against a hung build. When that budget fires, the catch block calls getCachedDailyMarketBrief(timezone) on line 1715 with no timeout. If the outer timeout was triggered by something that also leaves IndexedDB in a bad state, this unbounded read means the panel can still hang — exactly the failure mode the PR aims to close. Wrapping this call with the same .catch(() => null) pattern used on line 1652, or applying a short withTimeout, would make the recovery path bullet-proof.

Reviews (1): Last reviewed commit: "fix(daily-market-brief): cap LLM summari..." | Re-trigger Greptile

Comment thread tests/with-timeout.test.mts
Comment thread tests/with-timeout.test.mts
koala73 added 2 commits May 16, 2026 10:05
## 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.
@koala73
koala73 merged commit fbd9188 into main May 16, 2026
11 checks passed
@koala73
koala73 deleted the fix/daily-market-brief-timeouts branch May 16, 2026 06:17
@koala73

koala73 commented May 16, 2026

Copy link
Copy Markdown
Owner Author

Closing both Greptile P2 threads — Greptile reviewed only commit 1bcafebbd (round 1) and never re-fired on the follow-up commits, but both findings were addressed in round-2 commit 72fa3ab4a before merge:

  • tests/with-timeout.test.mts:100 (rejection-path onTimeout test) → added in round 2 as does not invoke onTimeout when the source rejects before the budget (line 91 of the merged file). The 80ms post-rejection wait proves the timer was cleared.
  • tests/with-timeout.test.mts:3 (.mjs.mts rename) → done via git mv in round 2; the merged file is .mts matching the daily-market-brief.test.mts convention.

Both verified live on main (fbd918802).

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.

1 participant