Skip to content

Handle duplicate Identity in SWA dictionary construction#53328

Closed
lewing wants to merge 1 commit intomainfrom
fix-swa-duplicate-identity
Closed

Handle duplicate Identity in SWA dictionary construction#53328
lewing wants to merge 1 commit intomainfrom
fix-swa-duplicate-identity

Conversation

@lewing
Copy link
Member

@lewing lewing commented Mar 9, 2026

Problem

When multiple Blazor WebAssembly client projects reference the same runtime pack (e.g. a host app with two WASM clients), NuGet cache assets like dotnet.js, dotnet.js.map, ICU data, and native wasm files get identical Identity values because Identity is derived from the file's FullPath. This causes ToDictionary/Dictionary.Add crashes in multiple SDK tasks:

error : ArgumentException: An item with the same key has already been added.
Key: .../.nuget/packages/microsoft.netcore.app.runtime.mono.browser-wasm/.../dotnet.js.map

This crash was introduced by dotnet/runtime#124125, which correctly changed WASM asset ContentRoot from a shared output directory to per-item %(RootDir)%(Directory) (the NuGet cache path). That fix was needed for SRI integrity on incremental builds (aspnetcore#65271), but it means multiple WASM clients now produce assets with the same Identity for shared NuGet cache files.

This blocks aspnetcore codeflow (dotnet/aspnetcore#65673).

Root Cause

The duplicate Identity scenario is valid, not a bug: Identity = FullPath, so same Identity means same physical file on disk. Multiple WASM clients legitimately reference the same NuGet cache files. The .Add() / .ToDictionary() calls assumed Identity uniqueness, which was true when Identity pointed to a per-project copy but is no longer true with the runtime#124125 fix.

Fix

Replace bare Dictionary.Add() and .ToDictionary() with ContainsKey + Add (skip-duplicate) pattern in all affected tasks:

  1. StaticWebAsset.ToAssetDictionary — used by 6+ tasks (ApplyCompressionNegotiation, ComputeEndpointsForReferenceStaticWebAssets, FilterStaticWebAssetEndpoints, GenerateStaticWebAssetEndpointsPropsFile, ResolveStaticWebAssetEndpointRoutes, and more)
  2. DiscoverPrecompressedAssets — the original crashing task (line 653 in targets)
  3. GenerateStaticWebAssetsManifest — crashes on publish
  4. GenerateStaticWebAssetEndpointsManifest — crashes on build

TryAdd is not available on net472, so the ContainsKey + Add pattern is used instead.

Testing

Validated with a multi-client WASM project (Host + Client1 + Client2 with different StaticWebAssetBasePath):

  • ✅ Build succeeds
  • ✅ Publish succeeds (both clients produce fingerprinted output)
  • ✅ Crash reproduces without fix (confirmed regression)
  • ✅ No runtime changes needed — this is an SDK-only fix

Why not copy pass-throughs to per-project location?

An alternative approach (copying NuGet cache pass-throughs to obj/) was prototyped in dotnet/runtime#125309, but:

  • It re-introduces the staleness problem that runtime#124125 fixed: if the runtime pack changes on incremental build, copies in obj/ go stale
  • It adds unnecessary I/O (copying files that don't need copying)
  • It conflicts with the long-term direction of minimizing asset copying

The SDK-only fix is simpler, more correct, and doesn't require runtime changes.

Closes dotnet/sdk#53305 (supersedes that approach)

When multiple WASM client projects reference the same runtime pack, NuGet
cache assets (dotnet.js, maps, ICU data, native wasm) get identical
Identity values because Identity is derived from the file's FullPath.
This causes ToDictionary/Dictionary.Add crashes in multiple SDK tasks.

The duplicate Identity scenario is valid, not a bug: Identity = FullPath,
so same Identity means same physical file on disk. Skip duplicates using
ContainsKey + Add instead of bare Add/ToDictionary.

Fixed in:
- StaticWebAsset.ToAssetDictionary (used by 6+ tasks)
- DiscoverPrecompressedAssets
- GenerateStaticWebAssetsManifest
- GenerateStaticWebAssetEndpointsManifest

Fixes the regression from dotnet/runtime#124125 that blocks aspnetcore
codeflow (dotnet/aspnetcore#65673).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 9, 2026 11:51
@github-actions github-actions bot added the Area-AspNetCore RazorSDK, BlazorWebAssemblySDK, StaticWebAssetsSDK label Mar 9, 2026
@dotnet-policy-service
Copy link
Contributor

Thanks for your PR, @@lewing.
To learn about the PR process and branching schedule of this repo, please take a look at the SDK PR Guide.

Copy link
Contributor

Copilot AI left a comment

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 updates Static Web Assets SDK tasks to tolerate duplicate Identity values when building Dictionary lookups, preventing ArgumentException: An item with the same key has already been added in multi-project scenarios (notably multiple Blazor WASM clients referencing the same runtime-pack assets in the NuGet cache).

Changes:

  • Replace ToDictionary(...)/Dictionary.Add(...) usage with guarded dictionary construction that skips duplicate keys.
  • Apply this pattern to manifest generation tasks and the precompressed-asset discovery task.
  • Update StaticWebAsset.ToAssetDictionary(...) to avoid throwing on duplicate identities.

Reviewed changes

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

File Description
src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsManifest.cs Avoids ToDictionary duplicate-key crashes when filtering publish endpoints.
src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs Avoids ToDictionary duplicate-key crashes when building the endpoints manifest.
src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs Makes ToAssetDictionary tolerant of duplicate Identity values.
src/StaticWebAssetsSdk/Tasks/Compression/DiscoverPrecompressedAssets.cs Avoids ToDictionary duplicate-key crashes when building candidate lookup.

Comment on lines +1237 to +1243
// Multiple projects may reference the same physical file (e.g. NuGet cache assets
// shared across WASM clients). Since Identity is the FullPath, same Identity means
// same file on disk — keeping the first occurrence is correct.
if (!dictionary.ContainsKey(candidateAsset.Identity))
{
dictionary.Add(candidateAsset.Identity, candidateAsset);
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

This change alters behavior in multiple tasks to tolerate duplicate Identity values, but there are existing unit tests for these tasks (e.g., GenerateStaticWebAssetEndpointsManifestTest, DiscoverPrecompressedAssetsTest, GenerateStaticWebAssetsManifestTest) that don't currently exercise the duplicate-identity scenario. Adding targeted tests that include two assets with the same Identity (and differing BasePath/TargetPath where applicable) would help prevent regressions and ensure the intended behavior is correct.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +37 to +44
var candidatesByIdentity = new Dictionary<string, StaticWebAsset>(candidates.Length, OSPath.PathComparer);
foreach (var candidate in candidates)
{
if (!candidatesByIdentity.ContainsKey(candidate.Identity))
{
candidatesByIdentity.Add(candidate.Identity, candidate);
}
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The duplicate-Identity guard uses ContainsKey(...) + Add(...), which performs two lookups per candidate. Prefer a single lookup with TryGetValue to keep this linear scan as cheap as possible (especially since this runs per build).

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +77
var manifestAssetsList = ComputeManifestAssets(assets, ManifestType);
var manifestAssets = new Dictionary<string, TargetPathAssetPair>(OSPath.PathComparer);
foreach (var a in manifestAssetsList)
{
if (!manifestAssets.ContainsKey(a.ResolvedAsset.Identity))
{
manifestAssets.Add(a.ResolvedAsset.Identity, a);
}
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

Building the lookup keyed only by ResolvedAsset.Identity and then skipping duplicates can select an arbitrary TargetPathAssetPair when the same physical file is included multiple times with different TargetPath/BasePath (e.g., multiple WASM clients). That can cause endpoints for one base path to be rewritten to a different target path. Consider keying by TargetPath (or Identity -> list) and selecting the matching entry per endpoint (e.g., by matching endpoint.Route/target path), or emitting an error when the same Identity maps to multiple distinct TargetPaths.

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +111
var assetsByIdentity = new Dictionary<string, StaticWebAsset>(assets.Length, OSPath.PathComparer);
foreach (var a in assets)
{
if (!assetsByIdentity.ContainsKey(a.Identity))
{
assetsByIdentity.Add(a.Identity, a);
}
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The new duplicate-Identity handling uses ContainsKey(...) followed by Add(...), which does two dictionary lookups per item. Since TryAdd isn't available on all TFMs, prefer a single lookup via TryGetValue (or attempt Add in a try/catch if you want to keep the fast path) to avoid unnecessary overhead on large asset lists.

Copilot uses AI. Check for mistakes.
Comment on lines +1237 to +1243
// Multiple projects may reference the same physical file (e.g. NuGet cache assets
// shared across WASM clients). Since Identity is the FullPath, same Identity means
// same file on disk — keeping the first occurrence is correct.
if (!dictionary.ContainsKey(candidateAsset.Identity))
{
dictionary.Add(candidateAsset.Identity, candidateAsset);
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

ToAssetDictionary now silently drops duplicate Identity entries. Even if the physical file is the same, different projects can legitimately attach different static-web-asset metadata (e.g., BasePath/RelativePath/AssetKind/CopyToPublishDirectory), and keeping the first one can produce incorrect downstream behavior. Consider validating that duplicates are equivalent (and error/warn if not), or changing the data structure to preserve all items (e.g., Identity -> list) for callers that need per-asset metadata.

Copilot uses AI. Check for mistakes.
@lewing
Copy link
Member Author

lewing commented Mar 9, 2026

Closing in favor of #53305 which takes the same approach (same 4 files, skip-duplicate pattern) but with better diagnostic logging and unit tests. My earlier comment on #53305 was based on the now-superseded runtime copy approach — the SDK-only fix is the right one. Apologies for the duplication.

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

Labels

Area-AspNetCore RazorSDK, BlazorWebAssemblySDK, StaticWebAssetsSDK

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants