Skip to content

fix(applications): bound type-acquisition extra libs so the Monaco worker can't OOM (#1499)#1500

Merged
dawsontoth merged 3 commits into
stagefrom
fix/monaco-ata-extralib-oom-1499
Jul 14, 2026
Merged

fix(applications): bound type-acquisition extra libs so the Monaco worker can't OOM (#1499)#1500
dawsontoth merged 3 commits into
stagefrom
fix/monaco-ata-extralib-oom-1499

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

What

Bound the @types extra libs that Automatic Type Acquisition feeds the Monaco language worker, so a large/deep dependency graph or a long multi-project session can't OOM the worker's structured-clone buffer.

Closes #1499 (a recurrence of #1370 through the one worker-clone path the earlier fixes never covered).

Why

setEagerModelSync(true) clones both models and every addExtraLib declaration to the TS/JS worker over postMessage. The guards added for #1370 / #1407 bound models only:

  • per-file MAX_WORKER_MODEL_CHARS (512 KB → plaintext),
  • aggregate MAX_APPLICATION_MODEL_CHARS_TOTAL (4 MB across live file:/// models),
  • MAX_LIVE_APPLICATION_MODELS (150).

acquireApplicationTypes → receivedFile called addExtraLib for every declaration file @typescript/ata walks, with no per-file cap, no aggregate budget, and a module-level acquiredPaths set that never resets — and extra libs are never swept on project switch. So they accumulate monotonically across every project opened in a session, invisible to the model budget (getModels() doesn't include extra libs), until the clone overflows → DataCloneError: Data cannot be cloned, out of memory.FAILED to post message to worker, repeated on every subsequent sync (the runaway per-session flood).

RUM evidence (last 24h, app f590deee-…)

Change

  • src/lib/monaco/workerLimits.ts: add MAX_EXTRA_LIB_CHARS_TOTAL (8 MB) and the pure canAdmitExtraLib(currentTotalChars, incomingChars) guard.
  • typeAcquisition.ts: track a session-lifetime acquiredChars and enforce the guard in receivedFile — skip any single declaration over the per-file limit, stop admitting once the total is spent. A rejected lib degrades that package to "cannot find module" (the same tradeoff selectFilesWithinModelBudget already makes) instead of crashing the tab.
  • workerLimits.test.ts: covers empty budget, exact-fit, single-oversized rejection, running-total overflow, and spent budget.

Not in this PR (follow-up, noted in #1499)

Extra libs are never removed on project switch (only models are swept), so a long multi-project session can still exhaust the budget and then acquire no further types. A fuller fix evicts stale extra libs when the active project changes to reclaim budget.

Test

  • New unit test green; existing modelHousekeeping / logEditorLanguage suites green.
  • Full pre-commit run: 1487 tests passed, oxlint + dprint clean, tsc --noEmit clean (Node 24).
  • Not browser-verified: reproducing the OOM needs a pathological large-dependency project against a live cluster; the fix is a pure, unit-tested accounting guard on the worker-clone path.

🤖 Generated with Claude Code

…rker can't OOM

Automatic Type Acquisition streamed every `@types` declaration into the
language worker via `addExtraLib` with no size cap, no aggregate budget, and a
module-level dedupe set that never resets. With `setEagerModelSync(true)` those
extra libs are structured-cloned to the worker just like models, but the model
guards (`MAX_WORKER_MODEL_CHARS`, `MAX_APPLICATION_MODEL_CHARS_TOTAL`, the
150-model ceiling) don't see them — extra libs aren't `getModels()` entries and
are never swept on project switch — so they accumulate monotonically across
every project opened in a session until the clone buffer overflows with
"DataCloneError: Data cannot be cloned, out of memory." followed by "FAILED to
post message to worker", flooding the session and wedging the worker.

RUM caught this recurring in prod on 2026-07-13 (1,560 combined occurrences,
~400 per affected session), a regression of #1370 through the one worker-clone
path its fixes never covered.

Add `MAX_EXTRA_LIB_CHARS_TOTAL` (8 MB) and `canAdmitExtraLib`, and enforce them
in `receivedFile`: skip any single declaration over the per-file limit and stop
admitting once the session total is spent. A rejected lib degrades that package
to "cannot find module" — the same tradeoff `selectFilesWithinModelBudget`
already makes for skipped sibling models — instead of crashing the tab.

Closes #1499

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dawsontoth
dawsontoth requested a review from a team as a code owner July 14, 2026 14:46
@dawsontoth dawsontoth added the rum From real user monitoring where we aim to keep users happy label Jul 14, 2026

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a character budget limit of 8 MB for automatically-acquired TypeScript declaration files to prevent out-of-memory (OOM) crashes in the Monaco language worker. It tracks the cumulative size of admitted libraries and validates incoming files using a new helper function, canAdmitExtraLib, which is backed by unit tests. The feedback suggests optimizing this process by short-circuiting the type acquisition early once the budget is exhausted, preventing unnecessary network requests and CPU usage.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 49.16% 4935 / 10037
🔵 Statements 49.58% 5259 / 10606
🔵 Functions 41.02% 1190 / 2901
🔵 Branches 42.26% 3237 / 7658
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/lib/monaco/workerLimits.ts 100% 100% 100% 100%
Generated in workflow #1493 for commit 7235414 by the Vitest Coverage Report Action

…s spent

Short-circuit `acquireApplicationTypes` when `acquiredChars` has already reached
`MAX_EXTRA_LIB_CHARS_TOTAL`: the budget is monotonic and never reclaimed, so the
acquisition engine would otherwise walk the CDN and parse declarations only for
`receivedFile` to discard every one. Avoids the wasted network/CPU once the cap
is hit (per PR review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bound genuinely closes the OOM as an invariant — addExtraLib is the sole extra-lib growth path and it's now hard-gated by canAdmitExtraLib, so the session total can't exceed the cap regardless of how many projects are opened. Nice. Three non-blocking notes:

  1. The silent drop isn't logged. When a lib is rejected, receivedFile returns with no diagnostic. Safe degradation, but from the field a user whose IntelliSense stops resolving a package has no signal why. A single console.debug/logger.warn on first budget exhaustion (and on per-file oversize rejection) makes "why did types stop working in this tab" diagnosable without a repro.
  2. The full-pass early-return is effectively dead code. acquiredChars is only incremented by an admitted file, and admission requires total + incoming <= MAX, so acquiredChars >= MAX is true only on an exact-fit landing — practically never. In the degraded long-session state it's meant for, the branch won't fire and every pass still wakes ATA + walks the CDN. The OOM is still prevented by the receivedFile guard, so this is a missed optimization, not a bug — but sealing the budget on aggregate exhaustion (e.g. an isBudgetSpent flag) would make the intended network-churn skip actually engage.
  3. The stateful path is untested. Only the pure guard has coverage; the accumulation + early-return aren't exercised — which is what would have caught #2.

None block merge.

🤖 Reviewed with KrAIs (Claude Opus 4.8)

…tion, log drops

Address PR review (kriszyp):

- The full-pass early-return was effectively dead: `acquiredChars` only advanced
  on an *admitted* file, so `acquiredChars >= MAX` was reachable only by an
  exact-fit landing and the network-churn skip practically never engaged. Move
  the accounting into an `ExtraLibBudget` that *seals* (`isSpent`) on the first
  aggregate overflow — a state that persists — so the early-return actually
  fires in the degraded long-session case it was written for.
- Silent drops now leave a breadcrumb: a one-time `console.warn` on the
  exhaustion transition and a `console.debug` per oversized declaration, so
  "why did IntelliSense stop resolving this package" is diagnosable without a
  repro.
- The stateful path is now tested: `ExtraLibBudget` is Monaco-free and unit
  tests cover accumulation, exact-fit-without-sealing, per-file oversize
  (which does not seal), the sealing overflow, and rejection-once-sealed.

No behavior change to the OOM invariant: `addExtraLib` remains hard-gated by
`admit()`, so the session total still can't exceed the cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Thanks @kriszyp — all three addressed in 7235414:

  1. Silent drops now logged — a one-time console.warn on the budget-exhaustion transition, and a console.debug per oversized-declaration skip, so "why did types stop resolving here" is diagnosable without a repro.
  2. Dead early-return fixed — you were right that acquiredChars >= MAX only landed on an exact fit. Accounting moved into an ExtraLibBudget that seals (isSpent) on the first aggregate overflow and stays sealed, so the full-pass skip actually engages in the degraded long-session state. The addExtraLib hard-gate is unchanged, so the cap invariant holds.
  3. Stateful path now testedExtraLibBudget is Monaco-free; unit tests cover accumulation, exact-fit-without-sealing, per-file oversize (no seal), the sealing overflow, and rejection-once-sealed.

🤖 Addressed by Claude Code

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — approving.

The bound is enforced in the right place: admit() gates addExtraLib before the payload is structured-cloned to the Monaco worker, so it caps clone-buffer growth at the source rather than after the fact. That's what actually closes the #1499 OOM. Cap semantics look sound — strict > overflow check (reachable ceiling, no off-by-one), per-file 512 KB check ahead of the 8 MB aggregate, seals once on first overflow — and acquiredPaths.add(path) is correctly moved ahead of the admit check so rejected libs are marked seen once (no re-eval, no log flood). The unit coverage is exactly the kind I like: it asserts behavior (empty / exact-fit / oversize-no-seal / overflow-seals-once / sealed-no-reflag), not a hardcoded count.

Non-blocking notes:

  • The permanent cap means extra libs aren't swept on project switch — a graceful UX degradation, and correctly deferred to the #1499 follow-up.
  • code.length is a UTF-16 char count, not bytes — consistent with the existing model budget, so fine, just flagging the unit.
  • PR body still describes a canAdmitExtraLib pure function; it shipped as the ExtraLibBudget class. Worth a quick body edit.

Reviewed with Claude (Opus 4.8), performance/crash-safety lens, on Kris's behalf.

@dawsontoth
dawsontoth added this pull request to the merge queue Jul 14, 2026
Merged via the queue into stage with commit c72fdd7 Jul 14, 2026
2 checks passed
@dawsontoth
dawsontoth deleted the fix/monaco-ata-extralib-oom-1499 branch July 14, 2026 21:07
dawsontoth added a commit that referenced this pull request Jul 21, 2026
Closes #1504. Follow-up to #1500, which added the safety bound and a
console-only breadcrumb; this surfaces the same degradation to the user.

Two silent degradations now show a dismissible notice above the editor:

- Type-acquisition budget exhausted: once the session-wide @types budget
  seals (ExtraLibBudget.isSpent), further packages report a spurious
  "cannot find module". A new isTypeAcquisitionBudgetSpent() surfaces the
  signal, and useApplicationTypeIntelligence now returns it as status.
- Oversized file: a file over MAX_WORKER_MODEL_CHARS renders as plaintext
  (the existing `oversized` flag).

The notice is a thin bar (role="status", aria-live="polite") sitting flush
below the toolbar so it never covers the first line of code; dismissal is
keyed per file+mode so it re-shows for a different file/degradation.

Wrapping the editor in a flex column needs a definite-height column
(h-full) and a flex-1/min-h-0 box, or Monaco's height:100% fails to
resolve and the editor collapses to a few px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Jul 22, 2026
Closes #1504. Follow-up to #1500, which added the safety bound and a
console-only breadcrumb; this surfaces the same degradation to the user.

Two silent degradations now show a dismissible notice above the editor:

- Type-acquisition budget exhausted: once the session-wide @types budget
  seals (ExtraLibBudget.isSpent), further packages report a spurious
  "cannot find module". A new isTypeAcquisitionBudgetSpent() surfaces the
  signal, and useApplicationTypeIntelligence now returns it as status.
- Oversized file: a file over MAX_WORKER_MODEL_CHARS renders as plaintext
  (the existing `oversized` flag).

The notice is a thin bar (role="status", aria-live="polite") sitting flush
below the toolbar so it never covers the first line of code; dismissal is
keyed per file+mode so it re-shows for a different file/degradation.

Wrapping the editor in a flex column needs a definite-height column
(h-full) and a flex-1/min-h-0 box, or Monaco's height:100% fails to
resolve and the editor collapses to a few px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rum From real user monitoring where we aim to keep users happy

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RUM] Monaco worker OOM recurrence via unbounded type-acquisition extraLibs (regression of #1370)

2 participants