Handle duplicate Identity in SWA dictionary construction#53328
Handle duplicate Identity in SWA dictionary construction#53328
Conversation
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>
|
Thanks for your PR, @@lewing. |
There was a problem hiding this comment.
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. |
| // 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); | ||
| } |
There was a problem hiding this comment.
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.
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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).
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| // 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); | ||
| } |
There was a problem hiding this comment.
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.
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 causesToDictionary/Dictionary.Addcrashes in multiple SDK tasks: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()withContainsKey+Add(skip-duplicate) pattern in all affected tasks:StaticWebAsset.ToAssetDictionary— used by 6+ tasks (ApplyCompressionNegotiation,ComputeEndpointsForReferenceStaticWebAssets,FilterStaticWebAssetEndpoints,GenerateStaticWebAssetEndpointsPropsFile,ResolveStaticWebAssetEndpointRoutes, and more)DiscoverPrecompressedAssets— the original crashing task (line 653 in targets)GenerateStaticWebAssetsManifest— crashes on publishGenerateStaticWebAssetEndpointsManifest— crashes on buildTryAddis not available on net472, so theContainsKey+Addpattern is used instead.Testing
Validated with a multi-client WASM project (Host + Client1 + Client2 with different StaticWebAssetBasePath):
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:obj/go staleThe SDK-only fix is simpler, more correct, and doesn't require runtime changes.
Closes dotnet/sdk#53305 (supersedes that approach)