Skip to content

fix: bound Monaco model lifecycle (#1407) and recover from worker-fallback breakage (#1406)#1435

Open
dawsontoth wants to merge 4 commits into
stagefrom
fix/monaco-model-leak-and-worker-fallback
Open

fix: bound Monaco model lifecycle (#1407) and recover from worker-fallback breakage (#1406)#1435
dawsontoth wants to merge 4 commits into
stagefrom
fix/monaco-model-leak-and-worker-fallback

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

Summary

Fixes the two related Monaco RUM issues from the daily reviews:

  • [RUM] Monaco editor: event listener leak (200-300+ listeners) on cluster view #1407 — listener leak (200–1000 listeners) / model leak. Each live Monaco text model holds a listener on a shared emitter; Monaco warns at 200 and every +100 after. Three paths grew the model population without bound:

    • useApplicationTypeIntelligence registered a model for every source file of the open project, uncapped — a 200+ file app trips the warning by design, and the same content is eagerly structured-cloned to the TS worker, producing the companion DataCloneError: … out of memory / FAILED to post message to worker errors (182 of each in one RUM session, which also logged the leak warning at 1000 listeners).
    • The effect cleanup skipped the model still attached to the editor, which keepCurrentModel then kept alive forever — one leaked model per project switch and per applications-view visit.
    • Installed packages loaded no intelligence at all, so every package file opened leaked its model permanently.

    Fix: new modelHousekeeping helpers — registration is budgeted (150 models / 4 MiB total, headroom under the 200-listener threshold, logged when trimmed), and a sweep disposes detached file:/// models whenever the open file's project/package context changes, plus a deferred sweep that catches the still-attached model once the editor releases it.

  • [RUM] Monaco editor: missing LSP request handlers (getFoldingRanges, findDocumentSymbols, findLinks, doValidation, getCodeAction) #1406Missing requestHandler or method: getFoldingRanges/findDocumentSymbols/findLinks/doValidation/getCodeAction. When a language worker fails to spawn, Monaco silently swaps in a main-thread fallback that cannot host foreign worker modules (monaco-yaml, JSON, …) and caches it — after which every language-feature call rejects with this error for the rest of the session. RUM shows all 7 affected sessions (161 events / 7 days) also carry the trigger: stale-deploy chunk failures (Failed to fetch dynamically imported module), network errors, or the [RUM] Monaco editor: event listener leak (200-300+ listeners) on cluster view #1407 OOM.

    Fix: installStaleDeployReload reloads once on Vite's vite:preloadError (rate-limited via sessionStorage so offline sessions don't loop), healing routes and workers after a redeploy; shouldKeepEvent drops the per-call error echoes from RUM since the root causes carry their own signals.

Verification

  • 1255 tests pass (14 new: model budget/sweep, reload guard, RUM filter), tsc/oxlint/dprint clean.
  • Live against stage data: opening a file in a demo app registers 13 models; three navigate-away/return cycles measure 13 → 0 → 13 → 0 → 13 → 0 live models (previously the count only grew). Editor, highlighting, and sibling-import intelligence still work.
  • Mechanism repro for [RUM] Monaco editor: missing LSP request handlers (getFoldingRanges, findDocumentSymbols, findLinks, doValidation, getCodeAction) #1406: blocking the YAML worker's spawn and opening config.yaml reproduces the per-call error spam; dispatching vite:preloadError reloads exactly once and a second dispatch inside the rate-limit window is suppressed.

Fixes #1407
Fixes #1406

🤖 Generated with Claude Code

dawsontoth and others added 2 commits July 9, 2026 10:58
…dels

Monaco warns "potential listener LEAK detected" once ~200 text models are
alive at the same time (each live TextModel holds a listener on a shared
emitter, warning again every +100 — matching the 200/300 progression in the
report, and one wild session that reached 1000). Three paths fed that
population from the applications editor:

- useApplicationTypeIntelligence registered a model for EVERY source file of
  the open project, unbounded. A 200+ file application trips the warning by
  design, and the same volume is eagerly structured-cloned to the TS worker —
  the source of the companion "DataCloneError: Data cannot be cloned, out of
  memory" / "FAILED to post message to worker" signatures (182 of each in a
  single RUM session).
- The effect cleanup skipped whichever model was still attached to the editor,
  and `keepCurrentModel` kept it alive on swap — leaking one model per project
  switch and per applications-view visit, forever.
- Installed packages loaded no intelligence at all, so every package file
  opened left a permanent model behind.

Registration is now budgeted (150 models / 4 MiB total, with headroom under
the 200-listener warning threshold) via selectFilesWithinModelBudget, and
sweepStaleApplicationModels disposes detached file:/// models whenever the
open file's project/package context changes — plus a deferred sweep at
cleanup that catches the still-attached model once the editor releases it.
Existing kept models are refreshed when their file's content changed.

Verified live against stage: opening a file registers editor + siblings
(13 models for the demo app), and three navigate-away/return cycles measure
13 -> 0 -> 13 -> 0 -> 13 -> 0 live models where the count previously only grew.

Fixes #1407

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lback spam

When a language worker fails to spawn — a redeploy invalidated this tab's
hashed chunks ("Failed to fetch dynamically imported module"), the network
dropped, or the tab hit the OOM fixed in #1407 — Monaco silently swaps in a
main-thread fallback worker and caches it. That fallback cannot host foreign
worker modules (monaco-yaml, JSON, ...), so EVERY language-feature call —
folding, links, symbols, validation, code actions — rejects with "Missing
requestHandler or method: <fn>" for the rest of the session. RUM shows 161
such events from 7 sessions in 7 days, each session also carrying its trigger:
chunk fetch failures or the #1407 DataCloneError OOM.

Two changes:

- installStaleDeployReload: reload once on Vite's `vite:preloadError` (the
  canonical recovery), so a stale deploy heals the session — routes AND
  workers — instead of leaving the editor silently degraded. Rate-limited via
  sessionStorage so persistent failures (offline) surface instead of looping.
- shouldKeepEvent: drop the "Missing requestHandler or method" per-call echoes
  from RUM. The root causes carry their own, far more useful signals; the
  echoes only add volume (dozens per broken session).

Reproduced live: blocking the YAML worker's spawn and opening config.yaml
yields the per-call error spam; dispatching `vite:preloadError` reloads once
and a second dispatch within the window is correctly suppressed.

Fixes #1406

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth dawsontoth requested a review from a team as a code owner July 9, 2026 15:00

@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 Monaco model housekeeping to prevent listener leaks and out-of-memory crashes by capping sibling models and sweeping stale ones. It also adds a self-healing reload mechanism to recover from stale-deploy chunk failures and filters out noisy worker-fallback errors in Datadog. Feedback highlights a sliding-window bug in the rate-limiting logic of the reload mechanism, and suggests using a Set instead of a single module-level variable to track active projects to prevent race conditions when multiple hook instances are mounted.

Comment thread src/lib/installStaleDeployReload.ts Outdated
Comment thread src/features/instance/applications/lib/modelHousekeeping.ts
Stamping sessionStorage on every vite:preloadError slid the cooldown window
forward, so failures arriving faster than the window (e.g. a reload that
landed on another stale copy of the app) suppressed recovery indefinitely.
Stamp only when actually reloading: a fixed cooldown from the last reload.

Review feedback from #1435.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kriszyp

kriszyp commented Jul 9, 2026

Copy link
Copy Markdown
Member

Fixes the two leaks it sets out to fix (#1407/#1406), with real tests covering the new bounded-registration and stale-sweep logic. A few things worth a look before this is fully closed out:

  1. Same-project manual browsing is still unbounded (useApplicationTypeIntelligence.ts:184). The housekeeping sweep only fires on [project, context, filesKey] change — none of which changes while staying inside one project — so opening 150+ distinct files within a single large project in one session still hits the exact 200-listener leak this PR targets. The front door this closes (project/context switches) isn't the only way in.
  2. Char budget ignores already-live model size (modelHousekeeping.ts:52). A registration batch starts totalChars at 0 and only counts against the 150-model cap, not the running character total — several already-live sibling models individually under MAX_WORKER_MODEL_CHARS can still sum past 4 MiB before the count cap even gets close. Fix looks mechanical: seed totalChars from the existing models' lengths, not just their count.
  3. Package-peek evicts the active project's own siblings (useApplicationTypeIntelligence.ts:189). Same root cause as Project easy studio #1 — every "peek" recomputes and re-fetches from scratch, silently abandoning any in-flight acquireApplicationTypes work for the departed context.
  4. monaco.Uri.toString() percent-encodes; the prefix-match literals don't (modelHousekeeping.ts:85, useApplicationTypeIntelligence.ts:222). A project name with a percent-encodable character would never match the sweep's prefix check. Narrowed to near-zero risk today since NewApplicationSchema restricts Studio-created project names to ^[a-zA-Z0-9-_]*$ — but it's not a new regression (same pattern existed in the code this replaces), so more a question for you than a blocker: worth fixing while touching this code, or defer?

None of these are blockers — the PR is a real improvement over what's there — but #1 and #2 in particular mean the "bounded" in the title isn't fully true yet for the same-project case.

— KrAIs

…n the model budget

Review follow-ups from #1435:

- Same-project browsing was still unbounded: no context switch fires while
  staying inside one project, so opening many files the budget skipped (or
  peeked library declarations) grew the model population past the ceiling. An
  onDidCreateModel enforcer now retires the oldest detached file:/// models
  once the tab-wide ceiling is exceeded, never touching the just-created or
  attached model.
- The character budget only seeded the model count, so a registration batch
  could stack another 4 MiB on top of already-live models. Both the count and
  the character budget now seed from every live file:/// model
  (getValueLength() is O(1)).
- A package peek no longer evicts the active project's sibling set: the last
  real project's models are kept while the context is an installed package
  (or no file), so a brief detour doesn't force a full refetch. Unmount still
  releases everything.
- Keep-prefixes are now built via monaco.Uri, so a project name that needs
  percent-encoding still matches uri.toString() output in the sweep.

Verified live against stage: a package peek keeps all 13 project models
(13 -> 14 -> 13, package model swept on return), and leaving the apps view
still sweeps to 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Thanks @kriszyp — all four addressed in b7a7e1d:

  1. Same-project browsing bounded: an onDidCreateModel enforcer (enforceModelCeiling) now retires the oldest detached file:/// models whenever the tab-wide ceiling is exceeded — covering files the budget skipped and peeked library declarations, which no context-switch sweep ever sees. It never touches the just-created model (still detached at creation time) or anything attached.
  2. Char budget seeds from live models: selectFilesWithinModelBudget now takes both a live-model count and a live-character total, seeded from every live file:/// model via getValueLength() (O(1)), so batches can't stack another 4 MiB on top of what's already synced.
  3. Package peeks no longer evict the project's siblings: the last real project's models are kept while the context is an installed package (or no file open), so a peek round-trip is 13 → 14 → 13 models instead of a full evict-and-refetch (verified live against stage). Unmount still releases everything — the deferred sweep with no claimant went from 13 → 0 in the same session.
  4. Percent-encoding fixed while here: keep-prefixes are now built via monaco.Uri (projectUriPrefix), matching uri.toString() output; the sweep's contract is documented and covered by an encoded-name test.

15 housekeeping tests now (6 new), full suite green.

🤖 Addressed by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants