Skip to content

feat(node,runtime-core,sdk): track and dispose evaluation-time side effects (#5031) - #5067

Open
justonemorenight wants to merge 15 commits into
module-federation:mainfrom
justonemorenight:feat/node-side-effect-scopes
Open

feat(node,runtime-core,sdk): track and dispose evaluation-time side effects (#5031)#5067
justonemorenight wants to merge 15 commits into
module-federation:mainfrom
justonemorenight:feat/node-side-effect-scopes

Conversation

@justonemorenight

Copy link
Copy Markdown

Description

Fixes #5031
Follows discussion in #4566 and architectural boundary proposal in #4566 (comment)

In long-running Node SSR hosts, re-registering or force-reloading remotes (registerRemotes(..., { force: true })) tears down internal runtime bookkeeping, but remotes registering global side effects at module evaluation time (e.g. setInterval, setTimeout, process.on / once / addListener / prependListener from logging, APM, or polling utilities) leak across generations. These active handles retain the previous generation's chunk code, resulting in linear memory growth per forced refresh.

Solution

  1. Side-Effect Scope Lifecycle (@module-federation/sdk):

    • Implemented withSideEffectScope(scopeId, fn) with an explicit LIFO execution stack and try/finally safety.
    • During synchronous execution of a remote module/chunk, patches setTimeout, setInterval, setImmediate, and process.on / once / addListener / prependListener to attribute handles to the active scopeId.
    • Restores original global methods immediately when execution scope exits.
    • Re-entrant evaluations (e.g. Remote A synchronously evaluating Remote B) properly restore outer scope state.
    • Idempotent cleanup via disposeRemoteSideEffects(scopeId).
  2. Wiring Points:

    • Entry Execution: Wrapped createScriptNode evaluation in @module-federation/sdk/node.
    • Chunk Execution: Wrapped compileChunk invocations in loadFromFs and fetchAndRun in @module-federation/node.
    • Module Factory Execution: Wrapped wrapModuleFactory in @module-federation/runtime-core.
    • Teardown Disposal: RemoteHandler.removeRemote calls disposeRemoteSideEffects when disposeSideEffects: true or force: true is requested.
  3. Tests & Benchmarks:

    • Added unit tests in @module-federation/sdk/__tests__/node-side-effects.spec.ts covering:
      • Timers, immediates, and process listeners.
      • LIFO nested scopes and re-entrant evaluations.
      • Exception safety (restores globals even when module factory throws).
    • Added integration test in @module-federation/runtime-core and @module-federation/runtime verifying side effects are cleared upon forced re-registration.
    • Added standalone memory benchmark in packages/node/__benchmarks__/remote-side-effects-memory.mjs.

ScriptedAlchemy and others added 15 commits September 4, 2026 21:36
…when cleanup misses

removeRemote (used by registerRemotes with force: true) looked up the
remote's runtime instance in __FEDERATION__.__INSTANCES__ only by the
registered name. When that differs from the name the remote was built
with, the lookup silently missed: the container global and module cache
were dropped but the old instance stayed registered, so every forced
re-registration leaked a full container graph.

The lookup now falls back to matching by entryGlobalName (the runtime
instance name for enhanced/webpack-built containers) and logs a warning
when no instance can be found instead of failing silently.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Remote chunks fetched over HTTP by the node runtime plugin were executed via
direct `eval` of a wrapper string. Functions created by direct eval capture the
enclosing scope, so every module factory in the chunk kept the fetched `data`
string alive in its closure context in addition to the flattened wrapper that
V8 retains as script source. A live 3 MB chunk therefore cost 6 MB, and in
long-running SSR hosts that re-execute remotes the duplicate copy came along
with every generation.

Introduce a shared `compileChunk` helper: on Node it compiles through
`vm.Script` with the chunk URL as the filename (same code path the filesystem
loader already used), and on runtimes without `vm` it uses `new Function`,
which does not capture scope. The stringified `httpEvalStrategy` in the
filesystem strategies gets the same treatment.

Measured on a 3 MB chunk with a heap snapshot after GC: one chunk source
string retained per live container instead of two, live-container heap
19.7 MB -> 16.7 MB, and 30 forced re-registrations stay flat at 17.7 MB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every distinct script V8 compiles lands in an isolate-wide compilation cache
holding its source text and compiled code. The cache never ages out on its own
(40 full GCs and 400 MB of unrelated churn left it intact) and is evicted only
when the heap approaches V8's own limit, which defaults from physical memory
rather than the container's cgroup. A host that force-registers a genuinely new
remote build on each refresh therefore grew about 6.4 MB per refresh with a
3 MB chunk, regardless of whether the chunk was compiled with eval, vm.Script,
new Function or vm.compileFunction, and heap snapshots never showed it because
writing a snapshot clears the cache.

Wrap the two places that compile remote code, loadScriptNode in the sdk and
compileChunk in the node runtime plugin, in withoutCompilationCache(), which
flips --no-compilation-cache around the synchronous compile call and restores
the flag afterwards. FEDERATION_KEEP_COMPILATION_CACHE=true opts out.

Measured (3 MB chunk, unique build per forced refresh, 40 refreshes): heap
16.8 MB -> 18.0 MB with the change, 16.8 MB -> 272.9 MB with the opt-out.
Identical-build refreshes are unchanged at 18.0 MB. An uncached 2 MB compile
costs about 37 ms against 4 ms cached, paid once per remote execution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…flag is unavailable

--no-compilation-cache is not accepted in NODE_OPTIONS, so hosts that only
control environment variables (Lambda-style runtimes) cannot use the startup
flag, and runtimes without v8.setFlagsFromString cannot use the scoped toggle.
Add FEDERATION_COMPILATION_CACHE=flag|gc|off: flag (default) keeps the scoped
toggle; gc compiles normally and then runs one full garbage collection through
the inspector once the compile burst settles, which clears the whole cache
(71 ms on a small heap, ~540 ms on a 500 MB heap, against 28 s for a heap
snapshot); off leaves the cache alone. gc is also the automatic fallback when
setFlagsFromString is missing. The GC timer is unref'd and the inspector
session is disconnected after post() returns, since disconnecting from inside
the synchronous callback deadlocks the session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g the inspector session

HeapProfiler.collectGarbage completes asynchronously; disconnecting right after
post() cancelled it, which left the gc strategy ineffective. Disconnect on the
next macrotask after the completion callback instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Node's permission model (--permission) denies inspector.Session.connect with
ERR_ACCESS_DENIED. When that happens the gc strategy now uses an exposed gc()
with V8's last-resort flavor, which also clears the compilation cache and can be
enabled through NODE_OPTIONS=--expose-gc on env-only hosts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drop the inspector-based and exposed-gc fallbacks; the compilation cache is
handled solely by flipping --no-compilation-cache around the synchronous
compile call, with FEDERATION_KEEP_COMPILATION_CACHE=true as the opt-out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Separate compilation from policy with a single implementation in the sdk:
`buildCommonJsWrapper` (wrapper shape), `compileCommonJsModule` (vm.Script
when vm is obtainable, otherwise new Function; errors never retried on the
other backend) and `withRemoteCompilationPolicy` (process-level manager for
V8's compilation cache flag: shared globalThis depth counter, toggled only on
0->1 and 1->0, skipped when the process already runs with
--no-compilation-cache or FEDERATION_REMOTE_COMPILATION_CACHE=default).
The helpers are re-exported through runtime-core and runtime so the node
plugin reaches them via __webpack_require__.federation.runtime, with a
new Function fallback for older runtimes. FEDERATION_KEEP_COMPILATION_CACHE
is removed. Adds sdk behaviour tests, node fallback and httpEvalStrategy
conformance tests, and a memory benchmark harness.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…icitly during removal

Introduce resolveRemoteRuntimeInstance with monotonic matching
(registered name + buildVersion, entryGlobalName + buildVersion, then
name-only levels only when no buildVersion is known), split removeRemote
into single-purpose steps, release shares by the instance's own
producer name, and warn only on ambiguous or version-mismatched lookups.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A direct eval is cached by V8 under the calling script. When the sdk runs
inside a remote entry, each new build's copy evaluating eval('require') left an
eval-cache entry that pinned that entry's source for the life of the process
(measured ~0.2 MB per forced refresh with new builds). Resolve builtins once
per process on a shared global, preferring process.getBuiltinModule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, not federation.runtime

Remove the compile helpers from the runtime and runtime-core exports; they
are Node execution infrastructure, not runtime APIs. The sdk gains
compileRemoteCommonJsModule (policy + compile), which loadScriptNode and the
node runtime plugin use directly. The plugin no longer looks the helpers up on
__webpack_require__.federation.runtime and no longer carries a new Function
fallback for older runtimes: it compiles through its own dependency. That also
removes the silent fallback path where a remote bundled against an older
runtime namespace compiled chunks with no cache policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e90a4b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 48 packages
Name Type
@module-federation/node Patch
@module-federation/sdk Patch
@module-federation/runtime-core Patch
@module-federation/runtime Patch
@module-federation/modern-js-v3 Patch
@module-federation/modern-js Patch
@module-federation/nextjs-mf Patch
@module-federation/rsbuild-plugin Patch
@module-federation/rstest Patch
node-dynamic-remote-new-version Patch
node-dynamic-remote Patch
@module-federation/devtools Patch
@module-federation/cli Patch
@module-federation/dts-plugin Patch
@module-federation/enhanced Patch
@module-federation/esbuild Patch
@module-federation/managers Patch
@module-federation/manifest Patch
@module-federation/metro Patch
@module-federation/observability-plugin Patch
@module-federation/retry-plugin Patch
@module-federation/rspack Patch
@module-federation/rspress-plugin Patch
@module-federation/storybook-addon Patch
@module-federation/utilities Patch
@module-federation/webpack-bundler-runtime Patch
@module-federation/bridge-react-webpack-plugin Patch
@module-federation/bridge-react Patch
@module-federation/bridge-vue3 Patch
@module-federation/playground Patch
@module-federation/runtime-tools Patch
website-new Patch
shared-tree-shaking-with-server-host Patch
shared-tree-shaking-with-server-provider Patch
remote5 Patch
remote6 Patch
shared-tree-shaking-no-server-host Patch
shared-tree-shaking-no-server-provider Patch
@module-federation/metro-plugin-rnc-cli Patch
@module-federation/metro-plugin-rnef Patch
@module-federation/metro-plugin-rock Patch
@module-federation/inject-external-runtime-core-plugin Patch
create-module-federation Patch
@module-federation/error-codes Patch
@module-federation/third-party-dts-extractor Patch
@module-federation/treeshake-frontend Patch
@module-federation/treeshake-server Patch
@module-federation/bridge-shared Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@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: e90a4b3df1

ℹ️ 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/sdk/src/node.ts
const scopeId =
(attrs && (attrs['name'] || attrs['globalName'])) || filename;

withSideEffectScope(scopeId, () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply the scope to ESM remote-entry evaluation

With a Node remote declared as type: 'module' or type: 'esm', only the CommonJS execution is scoped here; the ESM branch at packages/sdk/src/node.ts:346-353 calls module.evaluate() directly. A top-level setInterval or process.on in an ESM entry therefore has no bucket under attrs.name, so force re-registering that remote cannot dispose the effect. Wrap the ESM evaluation with the same scope ID.

Useful? React with 👍 / 👎.

const script = new vm.Script(
`(function(exports, require, __dirname, __filename) {${content}\n})`,
{
const scopeId = path.basename(filename);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Associate filesystem chunk effects with the remote

When the local-filesystem chunk path is used, this records registrations made by the chunk wrapper under a basename such as 123.js. Teardown only disposes remote.name and, optionally, entryGlobalName (packages/runtime-core/src/remote/index.ts:761-766), so force replacement cannot reach this bucket and leaves those timers or listeners active. Thread the remote name into this helper and use that same scope ID.

Useful? React with 👍 / 👎.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime/node: dispose timers and process listeners registered by a remote when it is force re-registered

2 participants