Skip to content

fix(runtime-core): allow failed async shared loads to retry - #5068

Open
dmchoi77 wants to merge 6 commits into
module-federation:mainfrom
dmchoi77:fix/runtime-shared-retry
Open

dmchoi77 wants to merge 6 commits into
module-federation:mainfrom
dmchoi77:fix/runtime-shared-retry

Conversation

@dmchoi77

@dmchoi77 dmchoi77 commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Description

This change fixes an issue where SharedHandler.loadShare() stores the Promise for an asynchronous shared-module load in the registered share entry but leaves the rejected Promise in the share scope after the load fails. After a transient failure, a later loadShare() call that selects the same provider/version reuses the same rejected Promise instead of invoking the provider's get() again.

This does not introduce automatic retries. It clears the failed in-flight Promise so the next consumer request can start a new load.

Changes:

  • Have setShared() clear loading on the actual registered share entry when an asynchronous shared load rejects.
  • Use an identity check so cleanup from an older load cannot remove a newer loading request.
  • Preserve the existing behavior for successful loads and concurrent callers: successful loads remain cached and concurrent callers share one in-flight Promise.
  • Add regression coverage for both registered and not-yet-registered selected providers, proving that a second loadShare() retries the provider after a transient failure.
  • Add a patch changeset for @module-federation/runtime-core.

Related Issue

  • Reference: #5006 — the same failure pattern was previously fixed for rejected remote-entry Promises in globalLoading

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

Validation

  • pnpm --filter @module-federation/runtime-core exec rstest __tests__/shared-diagnostics.spec.ts — 9/9 passed
  • pnpm exec turbo run test --filter=@module-federation/runtime-core — 128 tests passed
  • pnpm exec turbo run test --filter=@module-federation/runtime — 93 tests passed
  • pnpm exec turbo run build --filter=@module-federation/runtime-core --filter=@module-federation/runtime — passed
  • pnpm --filter @module-federation/runtime-core run lint — passed
  • pnpm exec prettier --check packages/runtime-core/src/shared/index.ts packages/runtime-core/__tests__/shared-diagnostics.spec.ts .changeset/runtime-shared-retry.md — passed

Checklist

  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have updated the documentation.

@changeset-bot

changeset-bot Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9b3a4de

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/runtime-core Patch
@module-federation/nextjs-mf Patch
@module-federation/runtime Patch
@module-federation/bridge-react Patch
@module-federation/devtools Patch
@module-federation/dts-plugin Patch
@module-federation/esbuild Patch
@module-federation/metro Patch
@module-federation/modern-js-v3 Patch
@module-federation/modern-js Patch
@module-federation/node Patch
@module-federation/observability-plugin Patch
@module-federation/playground Patch
@module-federation/retry-plugin Patch
@module-federation/runtime-tools Patch
@module-federation/webpack-bundler-runtime Patch
@module-federation/bridge-vue3 Patch
website-new Patch
@module-federation/metro-plugin-rnc-cli Patch
@module-federation/metro-plugin-rnef Patch
@module-federation/metro-plugin-rock Patch
shared-tree-shaking-with-server-host Patch
shared-tree-shaking-with-server-provider Patch
@module-federation/rsbuild-plugin Patch
@module-federation/rstest Patch
node-dynamic-remote-new-version Patch
node-dynamic-remote Patch
@module-federation/enhanced Patch
@module-federation/rspack Patch
@module-federation/inject-external-runtime-core-plugin Patch
@module-federation/rspress-plugin Patch
remote5 Patch
remote6 Patch
@module-federation/storybook-addon Patch
shared-tree-shaking-no-server-host Patch
shared-tree-shaking-no-server-provider Patch
@module-federation/cli Patch
create-module-federation Patch
@module-federation/error-codes Patch
@module-federation/managers Patch
@module-federation/manifest Patch
@module-federation/sdk Patch
@module-federation/third-party-dts-extractor Patch
@module-federation/treeshake-frontend Patch
@module-federation/treeshake-server Patch
@module-federation/bridge-react-webpack-plugin Patch
@module-federation/bridge-shared Patch
@module-federation/utilities 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: 34e3d0c821

ℹ️ 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-core/src/shared/index.ts Outdated
@dmchoi77
dmchoi77 force-pushed the fix/runtime-shared-retry branch from 34e3d0c to 4c018c0 Compare September 14, 2026 12:32
@2heal1

2heal1 commented Sep 22, 2026

Copy link
Copy Markdown
Member

The fix is correct and the tests confirm the retry behavior. One maintainability suggestion on where the cleanup lives — not a blocker.

Right now the failure-recovery side effect is attached inside setShared, a generic "merge/register shared metadata" method. That places the rollback far from where loading is created and awaited, and its correctness relies implicitly on merge() being write-only-if-empty plus the targetShared.loading === loading identity check. If merge ever changes to overwrite, or an intermediate assignment is introduced, that guard could silently stop matching.

Since loading is only ever produced in the two asyncLoadProcess() sites, clearing it at the source reads more cohesively — keeping "fail → clear" next to "fail → throw" and dropping the dependency on setShared/merge:

let loading: Promise<(() => T) | undefined>;
const asyncLoadProcess = async () => {
  try {
    const factory = await targetShared.get!();
    addUseIn(targetShared, host.options.name);
    targetShared.loaded = true;
    targetShared.lib = factory;
    return factory as () => T;
  } catch (e) {
    if (targetShared.loading === loading) {
      targetShared.loading = null;
    }
    throw e;
  }
};
loading = asyncLoadProcess();

The same pattern applies to the unregistered/resolver path. Current code works — this is purely about cohesion/future-proofing.

@dmchoi77

Copy link
Copy Markdown
Contributor Author

@2heal1 Thanks for the suggestion. Addressed in the latest changes.

The rejected-load cleanup is now handled inside the catch blocks of both asyncLoadProcess() implementations, and the cleanup side effect was removed from setShared().

I also preserved the unregistered resolver path and synchronous get() failures so retries still work correctly.

@ScriptedAlchemy

ScriptedAlchemy commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

I don't think we should merge this as is. Retrying a shared load adds another way for two versions of a singleton to end up in use.

I added an integration test with a runtime host and a webpack remote loaded over HTTP:

  • The host fails to load version 1 and starts a retry.
  • While the retry is pending, the remote loads and starts using version 2.
  • The retry then resolves and gives the host version 1. Both versions are now in use, each with its own state.

The test increments a counter from each side. Each side reads 1. With one shared instance, the second side would read 2.

If we retry, every consumer has to wait for the same singleton resolution. One consumer can't start using a version while another consumer's pending retry later resolves to a different one.

The same race already exists on the first load. This change makes it reachable after a failed load as well, which is why I'm wary of adding retries here. The control case passes, where the host finishes loading before the remote arrives.

The failing tests are in aa04ccb, with no runtime changes. The focused run has 12 passing tests and 2 failing race cases:

pnpm --filter @module-federation/runtime-core exec rstest run --include '**/__tests__/shared-retry-integration.spec.ts' --include '**/__tests__/shared-diagnostics.spec.ts'

@dmchoi77

Copy link
Copy Markdown
Contributor Author

@ScriptedAlchemy Thanks for the additional reproduction. I was able to reproduce both the initial-pending and retry-pending failures locally.

The current fix only removes rejected loading entries; it does not reserve singleton resolution. As a result, a remote can select version 2 while the host retry is still resolving version 1, causing two singleton instances to be used.

I propose serializing singleton resolution across runtime-core and the Webpack share scope:

  • reserve the selected singleton while its load is pending
  • make all consumers await and reuse the same in-flight Promise
  • clear the reservation and loading state across all scopes on rejection, using an identity check
  • keep the selected version and factory once the load succeeds

The alternative would be disabling retries for singleton shared modules, but that would retain the original transient-failure behavior for those modules.

Does this direction align with the expected behavior? I can implement it and keep the new integration cases as regression coverage.

@ScriptedAlchemy

Copy link
Copy Markdown
Member

Need to discuss this with @2heal1 as sharing is very sensitive. I don't think we should have reflect and rollback implemented on this. Too many race conditions for rollback.

This branch has not been deployed

No deployments
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.

3 participants