fix(applications): bound type-acquisition extra libs so the Monaco worker can't OOM (#1499)#1500
Conversation
…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>
There was a problem hiding this comment.
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.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
…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
left a comment
There was a problem hiding this comment.
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:
- The silent drop isn't logged. When a lib is rejected,
receivedFilereturns with no diagnostic. Safe degradation, but from the field a user whose IntelliSense stops resolving a package has no signal why. A singleconsole.debug/logger.warnon first budget exhaustion (and on per-file oversize rejection) makes "why did types stop working in this tab" diagnosable without a repro. - The full-pass early-return is effectively dead code.
acquiredCharsis only incremented by an admitted file, and admission requirestotal + incoming <= MAX, soacquiredChars >= MAXis 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 thereceivedFileguard, so this is a missed optimization, not a bug — but sealing the budget on aggregate exhaustion (e.g. anisBudgetSpentflag) would make the intended network-churn skip actually engage. - 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>
|
Thanks @kriszyp — all three addressed in 7235414:
🤖 Addressed by Claude Code |
kriszyp
left a comment
There was a problem hiding this comment.
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.lengthis a UTF-16 char count, not bytes — consistent with the existing model budget, so fine, just flagging the unit.- PR body still describes a
canAdmitExtraLibpure function; it shipped as theExtraLibBudgetclass. Worth a quick body edit.
Reviewed with Claude (Opus 4.8), performance/crash-safety lens, on Kris's behalf.
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>
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>
What
Bound the
@typesextra 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 everyaddExtraLibdeclaration to the TS/JS worker overpostMessage. The guards added for #1370 / #1407 bound models only:MAX_WORKER_MODEL_CHARS(512 KB → plaintext),MAX_APPLICATION_MODEL_CHARS_TOTAL(4 MB across livefile:///models),MAX_LIVE_APPLICATION_MODELS(150).acquireApplicationTypes → receivedFilecalledaddExtraLibfor every declaration file@typescript/atawalks, with no per-file cap, no aggregate budget, and a module-levelacquiredPathsset 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-…)…/apps/,…/instance/$instanceId/) — a different surface than the log viewer [Monitoring] Monaco editor worker OOM — DataCloneError / postMessage failure (regression since v2.115.4) #1370 originally hit.Change
src/lib/monaco/workerLimits.ts: addMAX_EXTRA_LIB_CHARS_TOTAL(8 MB) and the purecanAdmitExtraLib(currentTotalChars, incomingChars)guard.typeAcquisition.ts: track a session-lifetimeacquiredCharsand enforce the guard inreceivedFile— 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 tradeoffselectFilesWithinModelBudgetalready 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
modelHousekeeping/logEditorLanguagesuites green.tsc --noEmitclean (Node 24).🤖 Generated with Claude Code