Skip to content

Change loader module cache to use canonical RRI#5540

Open
backspace wants to merge 2 commits into
mainfrom
cs-11716-loader-internal-rri-keys
Open

Change loader module cache to use canonical RRI#5540
backspace wants to merge 2 commits into
mainfrom
cs-11716-loader-internal-rri-keys

Conversation

@backspace

Copy link
Copy Markdown
Contributor

If a prefix mapping exists, the prefix RRI is used as the key.

This adds VirtualNetwork#onMappingChange so the cache can be thrown away if prefix mappings are added or removed.

moduleCacheKey now folds every spelling of a module (real URL, virtual
alias, RRI prefix) to one RRI via unresolveURL, so the internal module
cache is RRI-native rather than keyed by the virtual-alias URL. All
spellings still collapse to one key (preserving single-class identity);
user-realm and bare-package identifiers pass through unchanged.

The RRI->URL relationship is only stable between realm-mapping changes,
so VirtualNetwork now exposes onMappingChange and the loader subscribes
to discard its RRI-keyed caches (modules, moduleCanonicalURLs,
knownDepsCache) whenever a mapping is added or removed. Realm-prefix
mappings are a small fixed set frozen after setup, so this fires rarely.

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

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   2h 59m 19s ⏱️ + 24m 20s
3 550 tests +1  3 535 ✅ +1  15 💤 ±0  0 ❌ ±0 
3 569 runs  +1  3 554 ✅ +1  15 💤 ±0  0 ❌ ±0 

Results for commit 2023cfa. ± Comparison against earlier commit 392b387.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   16m 13s ⏱️ ±0s
1 880 tests ±0  1 880 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 959 runs  ±0  1 959 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 2023cfa. ± Comparison against earlier commit 392b387.

@backspace
backspace marked this pull request as ready for review July 17, 2026 19:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 392b387c02

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/runtime-common/loader.ts Outdated
Each Loader subscribes to the VirtualNetwork's realm-mapping-change
notifications so it can discard its RRI-keyed caches on a remap. The
VirtualNetwork outlives any single loader — LoaderService replaces the
loader on every module edit, session boundary, and clearFetchCache — so
a loader that never unsubscribes stays pinned, along with its entire
compiled-module cache, by the listener the network still holds. Over a
long session these accumulate, and a later remap invokes every stale
callback.

Loader now retains the unsubscribe and exposes dispose() to release it;
LoaderService calls dispose() on the outgoing loader before replacing it
(and on service teardown). dispose() only detaches the listener — it does
not clear caches, since a clone made from the loader carries them forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@backspace
backspace requested a review from a team July 17, 2026 21:11
@habdelra
habdelra requested a review from Copilot July 18, 2026 00:39

Copilot AI 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.

Pull request overview

This PR makes the loader’s module cache key use the canonical RRI form (via VirtualNetwork.unresolveURL) so that different spellings of the same module (resolved URL, virtual alias URL, and prefix-form RRI) share a single cached module/class identity. Because realm-prefix mappings can change at runtime, it also introduces a realm-mapping change notification hook and uses it to discard affected loader caches, plus adds a Loader.dispose() mechanism to avoid leaking discarded loaders via long-lived VirtualNetwork listeners.

Changes:

  • Add VirtualNetwork.onMappingChange() (with unsubscribe) and notify listeners on realm-mapping add/remove.
  • Change Loader module-cache keying to canonical RRI (unresolveURL) and clear module caches on mapping changes.
  • Ensure discarded loaders unsubscribe via Loader.dispose(), and update LoaderService + unit tests accordingly.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/runtime-common/virtual-network.ts Adds mapping-change listener subscription surface and emits notifications on mapping add/remove.
packages/runtime-common/loader.ts Switches module cache key to canonical RRI, clears caches on mapping changes, and adds dispose() to unsubscribe.
packages/host/tests/unit/loader-test.ts Adds unit tests for cache discard on mapping change and for dispose() unsubscribing behavior.
packages/host/app/services/loader-service.ts Disposes previous loaders when replacing/resetting to prevent listener-pinned loader/cache leaks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +38 to +42
// Notified whenever a realm-prefix mapping is added or removed. Consumers
// that key caches by the RRI form a mapping produces (e.g. the Loader's
// module cache) subscribe here to discard those entries when the mapping
// set changes — the RRI→URL relationship is only stable between changes.
private mappingChangeListeners = new Set<() => void>();
Comment on lines +252 to 258
// Release the realm-mapping-change subscription so this loader can be
// garbage-collected once discarded. Only detaches the listener — it does not
// clear the caches, since a clone made from this loader carries them forward.
dispose() {
this.unsubscribeMappingChange?.();
this.unsubscribeMappingChange = undefined;
}

@habdelra habdelra 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.

[Claude Code 🤖] Review focus: the semantics of the new mapping-sensitive cache key (unresolveURL in moduleCacheKey), the invalidation/subscription lifecycle, and whether the leak fix covers every loader-replacement site. All claims below were verified against the checked-out branch, not the diff view.

Bottom line: no blocking issues. The RRI-keyed cache, the clear-on-mapping-change subscription, and the dispose() plumbing are correct as landed. One code comment misdescribes cloneLoader's behavior and should be fixed before merge (one-line change, thread on dispose() in loader.ts).

What lands right:

  • The cache key now matches the canonical identifier form the loader already emits everywhere else (canonicalIdentifier feeds identify() output and dependency lists) — one canonicalization scheme instead of two coexisting ones (virtual-alias for keys, RRI for outputs). I traced all three spellings through the new key: resolved real URL and RRI prefix collapse via the realmMappings scan, and the virtual-alias spelling collapses via the resolveURLMapping chase — single class identity holds across every spelling.
  • Clear-on-change converts the documented instability (RRI→URL is only stable between mapping changes) into an invalidation event rather than a stale-key hazard, and it's strictly safer than the orphaning the previous stable-key design accepted — detail and the boot-time-only production evidence in the thread on the constructor subscription.
  • The unsubscribe fix is complete: dispose() is called at all three loader-replacement sites in LoaderService (resetSessionBoundary, both resetLoader branches) plus the destructor. I grepped the tree for other production new Loader(/cloneLoader sites — there are none outside the host service (the realm server registers mappings at boot but constructs no VN-attached loaders); test-created loaders subscribe to per-test VNs that die with the test owner.
  • Both new tests ran in CI (confirmed in the merged host test report) and the net-zero add+remove probe in the dispose test is exactly the right observable for "subscription released".

Recommendations:

  1. Fix the dispose() comment — clones do not carry the caches forward; corrected wording suggested in the thread on packages/runtime-common/loader.ts. (Non-blocking but cheap and worth doing here.)
  2. Optional: reword the mapping-add assertion message in loader-test.ts so it doesn't overclaim — thread there has the detail.

Adjacent, out of scope:

  • moduleCacheKey now runs unresolveURL on every getModule/setModule. Cost is at parity with the removed path (both pay resolveURLMapping — URL construction plus RealmPaths — on the common user-realm miss; the base-realm real-URL case actually got cheaper since the realmMappings scan hits before any URL construction). If loader ops ever show up in render profiles again, a VN-level memo for unresolveURL mirroring toURLHrefCache (cleared on mapping change, which onMappingChange now makes trivial) is the natural follow-up.
  • knownDepsCache keys remain resolved-URL-form (resolveImport output) rather than moduleCacheKey form, so two spellings of one root can hold duplicate entries. Correctness is covered by the clear-on-change; unifying the keying is a nicety for whoever continues the RRI-native migration.
  • Percy reports 5 visual diffs awaiting review — worth a glance before merge; nothing in this diff should change rendering, so they're likely environmental, but that's for Percy review to confirm.

Comment on lines +241 to +249
// Module caches are keyed by canonical RRI form (see moduleCacheKey), whose
// relationship to a real URL is only stable between realm-mapping changes.
// Discard the RRI-keyed caches whenever a mapping is added or removed so an
// entry can't outlive the spelling it was keyed under.
this.unsubscribeMappingChange = this.virtualNetwork?.onMappingChange(() => {
this.modules.clear();
this.moduleCanonicalURLs.clear();
this.knownDepsCache.clear();
});

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.

[Claude Code 🤖] Confirmation — the clear-on-change design is correct as landed, and here are the conditions under which it stays that way. No change requested; this documents what I verified so the trade-off is on record.

What this replaces. The previous key was deliberately mapping-insensitive (virtual-alias URL via mapURL(real-to-virtual)), precisely so a mapping change couldn't orphan cached entries. This change inverts that: the key is now mapping-sensitive (unresolveURL), and the instability is handled by discarding the caches on every mapping change. The discard is strictly better than orphaning — an orphaned URL-form entry would become reachable again after a mapping remove restores the key form, re-serving a stale module copy; the discard makes that impossible.

Why the blast radius is acceptable in production. I enumerated every production caller of addRealmMapping/removeRealmMapping: host — makeVirtualNetwork() in packages/host/app/services/network.ts (base/catalog/skills/openrouter prefixes at service construction) and shimExternals in packages/host/app/lib/externals.ts (boxel-ui prefix); server — boot-time registration in packages/realm-server/main.ts and worker.ts (and the server constructs no VN-attached Loaders in production code at all — I grepped for every new Loader( site). RealmServerService.setAvailableRealmIdentifiers — the one path that changes the realm list at runtime — only mutates its in-memory availableRealms array, never the VN. So in production the listener fires only during boot, before any meaningful module cache exists; all mid-session add/remove traffic is test-scoped.

The condition under which this stops being safe. If realm-prefix mappings ever become runtime-dynamic in the host (e.g. registering user-realm prefixes at login or on workspace creation — a plausible direction for RRI-native work), each add wipes every live loader's compiled modules. Already-rendered card instances keep holding classes evaluated from the discarded cache while fresh imports re-evaluate new class objects, so instanceof / polymorphic-field identity checks across that boundary diverge. Whoever makes mappings dynamic needs to pair it with a loader-generation story, not just rely on this listener.

Mid-flight safety, verified. A clear during an in-flight import degrades to a refetch, not a wedge: advanceToState's loop re-reads getModule every iteration and its undefined arm calls fetchModule again, and Loader-level shims re-serve through _fetch's moduleShims lookup (keyed by URL, untouched by the clear), with fetchModule's shimmed branch re-establishing the module entry and canonical URL.

One caveat worth knowing. unresolveURL's second step chases resolveURLMapping(url, 'virtual-to-real'), so the key derivation also depends on urlMappings — and addURLMapping does not notify. That's fine under the current contract (urlMappings is append-only with no removal API and is populated only at boot, so it's outside the instability window), but if a removal API is ever added it needs to notify too.

Comment on lines +252 to 258
// Release the realm-mapping-change subscription so this loader can be
// garbage-collected once discarded. Only detaches the listener — it does not
// clear the caches, since a clone made from this loader carries them forward.
dispose() {
this.unsubscribeMappingChange?.();
this.unsubscribeMappingChange = undefined;
}

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.

[Claude Code 🤖] The comment's rationale for not clearing the caches is factually wrong: clones do not carry the caches forward. Non-blocking, but worth fixing before merge — it misdescribes cloneLoader's contract in exactly the place a future reader will look when deciding whether dispose() may clear.

What cloneLoader actually does. It constructs a fresh Loader (empty modules / moduleCanonicalURLs / knownDepsCache) and copies only moduleShims, re-registering each via clone.shimModule(...). The compiled-module state is deliberately not inherited — the clone rebuilds it from the HTTP fetch cache, which is the whole point of the swap (resetLoader's comment in packages/host/app/services/loader-service.ts says exactly this: "we keep the fetch cache so we can take advantage of HTTP caching when rebuilding the loader state"). So "a clone made from this loader carries them forward" can't be the reason not to clear; clearing the parent's maps would be invisible to any clone.

The real reasons not to clear, which the code gets right. (1) The discarded loader may still be draining in-flight work — LoaderService swaps this.loader while async imports against the old instance are mid-flight, and clearing under them would force re-evaluation and split class identity within those operations. (2) Clearing buys nothing for memory: once the listener is detached and the owner drops its reference, the maps are collected wholesale.

Suggested rewording:

Suggested change
// Release the realm-mapping-change subscription so this loader can be
// garbage-collected once discarded. Only detaches the listener — it does not
// clear the caches, since a clone made from this loader carries them forward.
dispose() {
this.unsubscribeMappingChange?.();
this.unsubscribeMappingChange = undefined;
}
// Release the realm-mapping-change subscription so this loader can be
// garbage-collected once discarded. Only detaches the listener — the caches
// are left intact because a discarded loader may still be draining in-flight
// imports, and once nothing references it the maps are collected wholesale.
dispose() {
this.unsubscribeMappingChange?.();
this.unsubscribeMappingChange = undefined;
}

Comment on lines +282 to +285
assert.false(
loader.isModuleLoaded(`${testRealmURL}f`),
'adding a realm mapping discards the module cache',
);

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.

[Claude Code 🤖] Nit, non-blocking: this assertion's message overclaims what it observes — it would pass even if the add discarded nothing. The dispose test's own comment explains the mechanism: a mapping add shifts the cache-key form itself. After addRealmMapping('@test-loader-discard/', testRealmURL), isModuleLoaded unresolves the lookup to @test-loader-discard/f, while an undiscarded entry would still sit under its import-time key ${testRealmURL}f — a miss either way. I traced both paths through moduleCacheKeyunresolveURL to confirm.

The assertion that actually pins the discard is the final one: after the remove restores the key form, a surviving entry would resurface and isModuleLoaded would return true. (Strictly, the pair pins "at least one of add/remove discards" — a remove-only implementation would also pass — but that disjunction is the observable contract; the stale-resurrection scenario is exactly what the last assertion catches, and there's no public-API probe that can distinguish which event did the discarding.)

Suggested message that matches what's observed:

Suggested change
assert.false(
loader.isModuleLoaded(`${testRealmURL}f`),
'adding a realm mapping discards the module cache',
);
assert.false(
loader.isModuleLoaded(`${testRealmURL}f`),
'module is not visible under its pre-mapping spelling after a mapping add',
);

The test comment at the top is accurate as written; only this message oversells. Also: the net-zero add+remove probe in the dispose test below, with its explanatory comment, is genuinely good test design — that comment will save the next person real time.

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.

3 participants