Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions packages/host/app/services/loader-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export default class LoaderService extends Service {
// clears the fetch cache and SSR-injected scoped styles in between tests
this.resetState();
}
registerDestructor(this, () => this.resetState());
registerDestructor(this, () => {
this.resetState();
this.loader?.dispose();
});
}

public resetState() {
Expand All @@ -57,9 +60,9 @@ export default class LoaderService extends Service {
this.resetTime = undefined;
log.debug(`resetting loader for session boundary (${reason ?? ''})`);
this.clearSessionCaches();
this.loader = this.loader
? Loader.cloneLoader(this.loader)
: this.makeInstance();
let previous = this.loader;
this.loader = previous ? Loader.cloneLoader(previous) : this.makeInstance();
previous?.dispose();
}

public resetLoader(options?: { clearFetchCache?: boolean; reason?: string }) {
Expand All @@ -71,6 +74,7 @@ export default class LoaderService extends Service {
this.resetTime = Date.now();
log.debug(`resetting loader (clearFetchCache, ${options.reason ?? ''})`);
clearFetchCache();
this.loader?.dispose();
this.loader = this.makeInstance();
return;
}
Expand All @@ -85,8 +89,10 @@ export default class LoaderService extends Service {
log.debug(`resetting loader (${options?.reason ?? ''})`);
// by default we keep the fetch cache so we can take advantage of HTTP
// caching when rebuilding the loader state
if (this.loader) {
this.loader = Loader.cloneLoader(this.loader);
let previous = this.loader;
if (previous) {
this.loader = Loader.cloneLoader(previous);
previous.dispose();
} else {
this.loader = this.makeInstance();
}
Expand Down
55 changes: 55 additions & 0 deletions packages/host/tests/unit/loader-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,61 @@ module('Unit | loader', function (hooks) {
}
});

test('a realm-mapping change discards the module cache', async function (assert) {
// The loader keys its module cache by canonical RRI form, whose
// relationship to a URL is only stable between mapping changes. A mapping
// add/remove must therefore discard the cache so an entry can't survive
// under a spelling that no longer resolves the same way.
let virtualNetwork = getService('network').virtualNetwork;
await loader.import(`${testRealmURL}f`);
assert.true(
loader.isModuleLoaded(`${testRealmURL}f`),
'module is cached after import',
);
virtualNetwork.addRealmMapping('@test-loader-discard/', testRealmURL);
try {
assert.false(
loader.isModuleLoaded(`${testRealmURL}f`),
'adding a realm mapping discards the module cache',
);
Comment on lines +282 to +285

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.

} finally {
virtualNetwork.removeRealmMapping('@test-loader-discard/');
}
assert.false(
loader.isModuleLoaded(`${testRealmURL}f`),
'removing a realm mapping also discards the module cache',
);
});

test('dispose() unsubscribes a discarded loader from mapping-change notifications', async function (assert) {
// LoaderService replaces its loader on every module edit / session
// boundary. Each loader subscribes to realm-mapping changes; without
// dispose() the VirtualNetwork keeps that subscription — pinning the
// loader and its whole module cache indefinitely. After dispose(), a
// mapping change must no longer reach this loader.
//
// Probe with a net-zero add+remove rather than isModuleLoaded straight
// after an add: a mapping add shifts the cache-key form itself, so a
// still-cached module would fail to look up under its original URL even
// when nothing was discarded. Restoring the mapping restores the key
// form, so a surviving cache entry is observable again — which it is only
// if the loader ignored both notifications.
let virtualNetwork = getService('network').virtualNetwork;
let discarded = Loader.cloneLoader(loader);
await discarded.import(`${testRealmURL}f`);
assert.true(
discarded.isModuleLoaded(`${testRealmURL}f`),
'module is cached after import',
);
discarded.dispose();
virtualNetwork.addRealmMapping('@test-loader-dispose/', testRealmURL);
virtualNetwork.removeRealmMapping('@test-loader-dispose/');
assert.true(
discarded.isModuleLoaded(`${testRealmURL}f`),
"a disposed loader's cache survives mapping changes (subscription released)",
);
});

test('isModuleLoaded returns false for a module that has not been imported', function (assert) {
assert.false(
loader.isModuleLoaded(`${testRealmURL}a`),
Expand Down
65 changes: 38 additions & 27 deletions packages/runtime-common/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@ export class Loader {
private fetchImplementation: Fetch;
private resolveImport: (moduleIdentifier: string) => string;
private virtualNetwork: VirtualNetwork | undefined;
// Unsubscribe for the realm-mapping-change listener registered below. The
// VirtualNetwork outlives any single loader (LoaderService replaces the
// loader on every module edit / session boundary), so a loader that isn't
// unsubscribed when it's discarded stays pinned — along with its whole
// module cache — by the listener the network still holds. `dispose()`
// releases it; the owner calls that before dropping the loader.
private unsubscribeMappingChange: (() => void) | undefined;
// When the host runs inside a prerender, `setTimeout` is suppressed by
// the render-timer-stub so the default sleep used by
// `fetchWithTransientRetry` would never resolve and a transient 5xx on
Expand All @@ -231,6 +238,23 @@ export class Loader {
resolveImport ?? ((moduleIdentifier) => moduleIdentifier);
this.retrySleep = options?.retrySleep;
this.virtualNetwork = options?.virtualNetwork;
// 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();
});
Comment on lines +241 to +249

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.

}

// 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;
}
Comment on lines +252 to 258
Comment on lines +252 to 258

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;
}


getVirtualNetwork(): VirtualNetwork | undefined {
Expand Down Expand Up @@ -833,36 +857,23 @@ export class Loader {
}
};

// Cache key for the per-module maps. Collapses the virtual-alias URL and the
// resolved real URL of the same module onto one key, so a base module
// imported via the alias (`https://cardstack.com/base/X`) and via the RRI
// prefix (`@cardstack/base/X` → resolveImport → resolved real URL) share one
// cached module — and therefore one class object. Without this, the alias
// and RRI forms evaluate as two distinct modules and `instanceof` /
// polymorphic-field identity checks across them diverge. Mirrors the
// real→virtual convention used by `canonicalizeTrackingKey`.
// Cache key for the per-module maps: the canonical RRI form. Every spelling
// of a module — its resolved real URL, the virtual-alias URL, and the RRI
// prefix — folds to one RRI via `unresolveURL`, so a base module reached by
// any of them shares one cached module and therefore one class object.
// Without this collapse the spellings evaluate as distinct modules and
// `instanceof` / polymorphic-field identity checks across them diverge.
// Modules with no realm-prefix mapping (user realms, bare package specifiers)
// are returned unchanged by `unresolveURL`.
//
// The key deliberately does NOT use the realm-prefix (RRI) form even
// though that's the canonical form for identifiers flowing out of the
// loader: realm-prefix mappings can be registered and removed while a
// loader is live (tests scope temporary prefixes via
// `addRealmMapping`/`removeRealmMapping`), and a mapping-sensitive key
// would orphan already-cached entries whenever a mapping changes —
// re-evaluating the same module under a new key and splitting class
// identities across the old and new copies.
// The RRI→URL relationship is only stable between realm-mapping changes, so
// the caches keyed here are discarded whenever a mapping is added or removed
// (see the `onMappingChange` subscription in the constructor).
private moduleCacheKey(moduleIdentifier: string): string {
let trimmed = trimModuleIdentifier(moduleIdentifier);
if (this.virtualNetwork) {
try {
let virtual = this.virtualNetwork.mapURL(trimmed, 'real-to-virtual');
if (virtual) {
return virtual.href;
}
} catch {
// not a parseable URL (e.g. a bare specifier) — fall through
}
}
return trimmed;
return this.virtualNetwork
? this.virtualNetwork.unresolveURL(trimmed)
: trimmed;
}

private getModule(moduleIdentifier: string): Module | undefined {
Expand Down
20 changes: 20 additions & 0 deletions packages/runtime-common/virtual-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,29 @@ export class VirtualNetwork {
// mapping is added or removed (both clear the cache).
private toURLHrefCache = new Map<string, string>();

// 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 +38 to +42

constructor(nativeFetch = createEnvironmentAwareFetch()) {
this.nativeFetch = nativeFetch;
this.mount(this.packageShimHandler.handle);
}

// Subscribe to realm-mapping changes; returns an unsubscribe function.
onMappingChange(listener: () => void): () => void {
this.mappingChangeListeners.add(listener);
return () => this.mappingChangeListeners.delete(listener);
}

private notifyMappingChange() {
for (let listener of this.mappingChangeListeners) {
listener();
}
}

resolveImport = (moduleIdentifier: string) => {
for (let [prefix, handler] of this.importMap) {
if (moduleIdentifier.startsWith(prefix)) {
Expand Down Expand Up @@ -98,6 +116,7 @@ export class VirtualNetwork {
normalizedId,
(rest) => new URL(rest, normalizedTarget).href,
);
this.notifyMappingChange();
}

/**
Expand All @@ -111,6 +130,7 @@ export class VirtualNetwork {
this.realmMappings.delete(normalizedId);
this.importMap.delete(normalizedId);
this.toURLHrefCache.clear();
this.notifyMappingChange();
}

knownRealms(): RealmIdentifier[] {
Expand Down
Loading