Skip to content

fix(Localizer): makes JsonStringLocalizerFactory thread-safe#7919

Merged
ArgoZhang merged 8 commits intomainfrom
fix-localizer-json
Apr 27, 2026
Merged

fix(Localizer): makes JsonStringLocalizerFactory thread-safe#7919
ArgoZhang merged 8 commits intomainfrom
fix-localizer-json

Conversation

@ArgoZhang
Copy link
Copy Markdown
Member

@ArgoZhang ArgoZhang commented Apr 27, 2026

Link issues

fixes #7918

Summary By Copilot

Regression?

  • Yes
  • No

Risk

  • High
  • Medium
  • Low

Verification

  • Manual (required)
  • Automated

Packaging changes reviewed?

  • Yes
  • No
  • N/A

☑️ Self Check before Merge

⚠️ Please check all items below before review. ⚠️

  • Doc is updated/provided or not needed
  • Demo is updated/provided or not needed
  • Merge the latest code from the main branch

Summary by Sourcery

Fix thread-local handling of type names in JSON string localization to prevent concurrency issues and incorrect resource resolution.

Bug Fixes:

  • Ensure JsonStringLocalizerFactory stores the current type name in an AsyncLocal field so concurrent requests don't overwrite each other's value.
  • Default to the base resource name when no type name is available to avoid incorrect resource lookup and type mismatches.

Tests:

  • Add a unit test verifying that CreateResourceManagerStringLocalizer falls back to the base name when the type name is null and reports the correct searched location.

Copilot AI review requested due to automatic review settings April 27, 2026 01:03
@bb-auto bb-auto Bot added the bug Something isn't working label Apr 27, 2026
@bb-auto bb-auto Bot added this to the v10.5.0 milestone Apr 27, 2026
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 27, 2026

Reviewer's Guide

Makes JsonStringLocalizerFactory thread-safe by storing the current type name in AsyncLocal and resetting it, and adds a unit test to verify fallback behavior when the type name is not set.

Sequence diagram for AsyncLocal type name usage in JsonStringLocalizerFactory

sequenceDiagram
    participant Caller
    participant JsonStringLocalizerFactory
    participant TypeNameContext
    participant JsonStringLocalizer

    Caller->>JsonStringLocalizerFactory: GetResourcePrefix(typeInfo)
    JsonStringLocalizerFactory->>JsonStringLocalizerFactory: compute typeName from typeInfo
    JsonStringLocalizerFactory->>TypeNameContext: _typeName.Value = typeName
    JsonStringLocalizerFactory-->>Caller: resourcePrefix

    Caller->>JsonStringLocalizerFactory: CreateResourceManagerStringLocalizer(assembly, baseName)
    JsonStringLocalizerFactory->>TypeNameContext: read _typeName.Value
    alt typeName is not null
        JsonStringLocalizerFactory->>JsonStringLocalizerFactory: typeName = _typeName.Value
    else typeName is null
        JsonStringLocalizerFactory->>JsonStringLocalizerFactory: typeName = baseName
    end
    JsonStringLocalizerFactory->>TypeNameContext: _typeName.Value = null
    JsonStringLocalizerFactory->>JsonStringLocalizer: new JsonStringLocalizer(assembly, typeName, baseName, options, logger, cache, handler)
    JsonStringLocalizer-->>JsonStringLocalizerFactory: instance
    JsonStringLocalizerFactory-->>Caller: JsonStringLocalizer instance
Loading

Updated class diagram for JsonStringLocalizerFactory concurrency fix

classDiagram
    class ResourceManagerStringLocalizerFactory

    class JsonStringLocalizerFactory {
        -ILoggerFactory _loggerFactory
        -JsonLocalizationOptions _jsonLocalizationOptions
        -ILocalizationMissingItemHandler _localizationMissingItemHandler
        -AsyncLocal~string?~ _typeName
        +GetResourcePrefix(typeInfo TypeInfo) string
        +GetResourcePrefix(baseResourceName string, baseNamespace string) string
        +CreateResourceManagerStringLocalizer(assembly Assembly, baseName string) ResourceManagerStringLocalizer
    }

    class JsonStringLocalizer

    class ILoggerFactory
    class JsonLocalizationOptions
    class ILocalizationMissingItemHandler
    class AsyncLocal~T~
    class ResourceManagerStringLocalizer
    class Assembly
    class TypeInfo

    JsonStringLocalizerFactory --|> ResourceManagerStringLocalizerFactory
    JsonStringLocalizerFactory --> ILoggerFactory : uses
    JsonStringLocalizerFactory --> JsonLocalizationOptions : uses
    JsonStringLocalizerFactory --> ILocalizationMissingItemHandler : uses
    JsonStringLocalizerFactory --> AsyncLocal~T~ : holds
    JsonStringLocalizerFactory --> JsonStringLocalizer : creates
    JsonStringLocalizerFactory --> ResourceManagerStringLocalizer : returns
    JsonStringLocalizerFactory --> Assembly : parameter
    JsonStringLocalizerFactory --> TypeInfo : parameter
Loading

File-Level Changes

Change Details Files
Make JsonStringLocalizerFactory store the current type name in an AsyncLocal to avoid cross-thread leakage and reset it when creating the localizer.
  • Replaced the shared string field used for the current type name with an AsyncLocal<string?> to provide per-async-flow storage.
  • Updated GetResourcePrefix overloads to assign the computed type name into the AsyncLocal.Value instead of a normal field.
  • Modified CreateResourceManagerStringLocalizer to read the type name from AsyncLocal, fall back to the baseName when null, and clear the AsyncLocal after use.
src/BootstrapBlazor/Localization/Json/JsonStringLocalizerFactory.cs
Add unit test to validate CreateResourceManagerStringLocalizer fallback behavior when no type name is set.
  • Added a test that invokes CreateResourceManagerStringLocalizer via reflection on the factory and ensures that when no type name has been preset, the baseName is used as the searched location.
  • Verified that a missing resource key reports ResourceNotFound and the expected SearchedLocation value.
test/UnitTest/Localization/JsonStringLocalizerTest.cs

Assessment against linked issues

Issue Objective Addressed Explanation
#7918 Ensure JsonStringLocalizerFactory handles _typeName in a concurrency-safe manner so that concurrent calls do not cause incorrect type names or data types.
#7918 Ensure CreateResourceManagerStringLocalizer behaves correctly when no _typeName has been set (e.g., fall back to baseName) and is covered by tests.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@ArgoZhang ArgoZhang changed the title fix(Localizer): concurrency issues caused incorrect data types due to _typeName. fix(Localizer): makes JsonStringLocalizerFactory thread-safe Apr 27, 2026
@ArgoZhang ArgoZhang merged commit 66a514a into main Apr 27, 2026
3 of 4 checks passed
@ArgoZhang ArgoZhang deleted the fix-localizer-json branch April 27, 2026 01:07
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The new unit test reaches into the factory via reflection to invoke the non-public CreateResourceManagerStringLocalizer, which makes the test brittle against refactors; consider instead asserting the same behavior through the public IStringLocalizerFactory API so the test validates observable behavior without depending on private implementation details.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new unit test reaches into the factory via reflection to invoke the non-public `CreateResourceManagerStringLocalizer`, which makes the test brittle against refactors; consider instead asserting the same behavior through the public `IStringLocalizerFactory` API so the test validates observable behavior without depending on private implementation details.

## Individual Comments

### Comment 1
<location path="test/UnitTest/Localization/JsonStringLocalizerTest.cs" line_range="334-343" />
<code_context>
     }

+    [Fact]
+    public void CreateResourceManagerStringLocalizer_UseBaseNameWhenTypeNameIsNull()
+    {
+        var factory = Context.Services.GetRequiredService<IStringLocalizerFactory>();
+        var mi = factory.GetType().GetMethod("CreateResourceManagerStringLocalizer", BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+        var baseName = typeof(Foo).FullName!;
+        var localizer = Assert.IsType<IStringLocalizer>(mi.Invoke(factory, [typeof(Foo).Assembly, baseName]), exactMatch: false);
+        var result = localizer["not-found-key"];
+
+        Assert.True(result.ResourceNotFound);
+        Assert.Equal(baseName, result.SearchedLocation);
+    }
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test that covers reuse of the factory to ensure the AsyncLocal type name is cleared between invocations.

To also cover the new AsyncLocal behavior and the underlying concurrency bug, please add a test that calls `CreateResourceManagerStringLocalizer` twice: once after a `GetResourcePrefix` call (so `_typeName` is set) and then again without setting `_typeName` (or after clearing it). The second call should rely on `baseName` and must not reuse the previous type name, confirming that `_typeName.Value` is correctly reset and protecting against regressions in the cleanup logic.

Suggested implementation:

```csharp
        Assert.Equal(baseName, result.SearchedLocation);
    }

    [Fact]
    public void CreateResourceManagerStringLocalizer_AsyncLocalTypeNameIsClearedBetweenInvocations()
    {
        var factory = Context.Services.GetRequiredService<IStringLocalizerFactory>();
        var factoryType = factory.GetType();

        var getResourcePrefix = factoryType.GetMethod(
            "GetResourcePrefix",
            BindingFlags.NonPublic | BindingFlags.Instance);

        var createLocalizer = factoryType.GetMethod(
            "CreateResourceManagerStringLocalizer",
            BindingFlags.NonPublic | BindingFlags.Instance)!;

        var fooType = typeof(Foo);
        var baseName = fooType.FullName!;

        // First invocation: simulate setting the AsyncLocal _typeName via GetResourcePrefix
        _ = getResourcePrefix!.Invoke(factory, new object[] { fooType });

        var localizer1 = Assert.IsType<IStringLocalizer>(
            createLocalizer.Invoke(factory, new object[] { fooType.Assembly, baseName }),
            exactMatch: false);

        var firstResult = localizer1["not-found-key"];
        Assert.True(firstResult.ResourceNotFound);

        // Second invocation without setting _typeName again. This must rely solely on baseName
        // and must not reuse the previous AsyncLocal type name.
        var localizer2 = Assert.IsType<IStringLocalizer>(
            createLocalizer.Invoke(factory, new object[] { fooType.Assembly, baseName }),
            exactMatch: false);

        var secondResult = localizer2["not-found-key"];

        Assert.True(secondResult.ResourceNotFound);
        Assert.Equal(baseName, secondResult.SearchedLocation);
    }

```

This test assumes:
1. The factory under test exposes a non-public instance method named `GetResourcePrefix` that accepts a single `Type` (or compatible) parameter. If the actual signature differs (e.g., `TypeInfo`), adjust the `Invoke` call accordingly, for example:
   - `new object[] { fooType.GetTypeInfo() }` for a `TypeInfo` parameter.
2. `using System.Reflection;` and `using Microsoft.Extensions.Localization;` are already present at the top of the file.
If the method name or binding flags differ in your implementation, update the reflection calls to match the actual factory API.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +334 to +343
public void CreateResourceManagerStringLocalizer_UseBaseNameWhenTypeNameIsNull()
{
var factory = Context.Services.GetRequiredService<IStringLocalizerFactory>();
var mi = factory.GetType().GetMethod("CreateResourceManagerStringLocalizer", BindingFlags.NonPublic | BindingFlags.Instance)!;

var baseName = typeof(Foo).FullName!;
var localizer = Assert.IsType<IStringLocalizer>(mi.Invoke(factory, [typeof(Foo).Assembly, baseName]), exactMatch: false);
var result = localizer["not-found-key"];

Assert.True(result.ResourceNotFound);
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.

suggestion (testing): Add a test that covers reuse of the factory to ensure the AsyncLocal type name is cleared between invocations.

To also cover the new AsyncLocal behavior and the underlying concurrency bug, please add a test that calls CreateResourceManagerStringLocalizer twice: once after a GetResourcePrefix call (so _typeName is set) and then again without setting _typeName (or after clearing it). The second call should rely on baseName and must not reuse the previous type name, confirming that _typeName.Value is correctly reset and protecting against regressions in the cleanup logic.

Suggested implementation:

        Assert.Equal(baseName, result.SearchedLocation);
    }

    [Fact]
    public void CreateResourceManagerStringLocalizer_AsyncLocalTypeNameIsClearedBetweenInvocations()
    {
        var factory = Context.Services.GetRequiredService<IStringLocalizerFactory>();
        var factoryType = factory.GetType();

        var getResourcePrefix = factoryType.GetMethod(
            "GetResourcePrefix",
            BindingFlags.NonPublic | BindingFlags.Instance);

        var createLocalizer = factoryType.GetMethod(
            "CreateResourceManagerStringLocalizer",
            BindingFlags.NonPublic | BindingFlags.Instance)!;

        var fooType = typeof(Foo);
        var baseName = fooType.FullName!;

        // First invocation: simulate setting the AsyncLocal _typeName via GetResourcePrefix
        _ = getResourcePrefix!.Invoke(factory, new object[] { fooType });

        var localizer1 = Assert.IsType<IStringLocalizer>(
            createLocalizer.Invoke(factory, new object[] { fooType.Assembly, baseName }),
            exactMatch: false);

        var firstResult = localizer1["not-found-key"];
        Assert.True(firstResult.ResourceNotFound);

        // Second invocation without setting _typeName again. This must rely solely on baseName
        // and must not reuse the previous AsyncLocal type name.
        var localizer2 = Assert.IsType<IStringLocalizer>(
            createLocalizer.Invoke(factory, new object[] { fooType.Assembly, baseName }),
            exactMatch: false);

        var secondResult = localizer2["not-found-key"];

        Assert.True(secondResult.ResourceNotFound);
        Assert.Equal(baseName, secondResult.SearchedLocation);
    }

This test assumes:

  1. The factory under test exposes a non-public instance method named GetResourcePrefix that accepts a single Type (or compatible) parameter. If the actual signature differs (e.g., TypeInfo), adjust the Invoke call accordingly, for example:
    • new object[] { fooType.GetTypeInfo() } for a TypeInfo parameter.
  2. using System.Reflection; and using Microsoft.Extensions.Localization; are already present at the top of the file.
    If the method name or binding flags differ in your implementation, update the reflection calls to match the actual factory API.

Copy link
Copy Markdown
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

Fixes a JsonStringLocalizerFactory concurrency issue where a shared _typeName could bleed across concurrent requests and lead to incorrect type resolution in localization.

Changes:

  • Replace the shared _typeName field with an AsyncLocal<string?> and clear it after localizer creation to isolate concurrent flows.
  • Add a unit test ensuring CreateResourceManagerStringLocalizer falls back to baseName when _typeName is not set.
  • Update/extend table dynamic-context tests and relax an assertion around Blazor ChangeDetection caching behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/BootstrapBlazor/Localization/Json/JsonStringLocalizerFactory.cs Uses AsyncLocal for per-execution-context _typeName to prevent cross-request contamination.
test/UnitTest/Localization/JsonStringLocalizerTest.cs Adds a regression test for fallback behavior when _typeName is null.
test/UnitTest/Components/TableTest.cs Adds dynamic-context edit-mode tests; weakens ChangeDetection caching assertions (currently commented out).
src/BootstrapBlazor/BootstrapBlazor.csproj Bumps package version to 10.5.2-beta01.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Assert.True(result.ResourceNotFound);
Assert.Equal(baseName, result.SearchedLocation);
}

Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This change fixes a concurrency issue in the localizer factory, but there isn’t a regression test that exercises concurrent IStringLocalizerFactory.Create(...) calls (e.g., in parallel tasks) to ensure SearchedLocation/type resolution doesn’t bleed across requests. Adding a small parallel test would help prevent this from reappearing.

Suggested change
[Fact]
public async Task Create_Ok_InParallel()
{
var factory = Context.Services.GetRequiredService<IStringLocalizerFactory>();
var expectedNamedSearchedLocation = factory.Create("Lang", "UnitTest")["not-found-key"].SearchedLocation;
var expectedTypedSearchedLocation = factory.Create(typeof(Foo))["not-found-key"].SearchedLocation;
var tasks = Enumerable.Range(0, 50).Select(async index =>
{
await Task.Yield();
if (index % 2 == 0)
{
var localizer = factory.Create("Lang", "UnitTest");
var result = localizer[$"not-found-key-{index}"];
Assert.True(result.ResourceNotFound);
Assert.Equal(expectedNamedSearchedLocation, result.SearchedLocation);
}
else
{
var localizer = factory.Create(typeof(Foo));
var result = localizer[$"not-found-key-{index}"];
Assert.True(result.ResourceNotFound);
Assert.Equal(expectedTypedSearchedLocation, result.SearchedLocation);
}
});
await Task.WhenAll(tasks);
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(Localizer): concurrency issues caused incorrect data types due to _typeName.

2 participants