mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 08:41:51 +08:00
9600f0de9c
# Description
A server did not start in globalization-invariant mode
(`InvariantGlobalization`, or
`DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`), which Native AOT and
container images often enable. When the server loaded its default status
texts, `ResourceManager` created `CultureInfo("en-US")`. In this mode
the runtime throws `CultureNotFoundException` for every culture except
the invariant one, so the server crashed in
`ResourceManager.LoadDefaultText`.
`ResourceManager` now:
- keeps the locale id of each translation table as a string, and falls
back to its language subtag to find another region of the same language;
- when the runtime cannot create the culture, parses a locale id that
matches the grammar of RFC 5646 §2.1 into the recommended casing of its
subtags. It still rejects a language without a region, and rejects a tag
that does not match the grammar, as before;
- caches the resolved locale ids, so loading the default texts creates
each culture once.
`docs/NativeAoT.md` no longer warns against invariant mode for servers.
The new tests (`ResourceManagerInvariantGlobalizationTests`) reproduce
both invariant-mode behaviors of the runtime: no cultures at all, and
cultures that carry invariant data. They also cover malformed tags, and
well-formed tags with extended language, variant, extension, and private
use subtags.
The branch includes a merge of master: #4707 moved translation lookups
to a lock-free snapshot of the tables, and the snapshot now copies the
locale-id-based tables.
### Validation
| Command | Result |
| --- | --- |
| `dotnet test tests/Opc.Ua.Server.Tests -f net10.0
-p:CustomTestTarget=net10.0 --filter
"FullyQualifiedName~ResourceManager"` | 100 passed, including the 39
tests of the new fixture. Before the fix, 10 of its first 22 tests
failed; 7 of the 17 grammar cases failed against the first version of
the parser |
| `dotnet test tests/Opc.Ua.Server.Tests -f net48
-p:CustomTestTarget=net48 --filter "FullyQualifiedName~ResourceManager"`
| 100 passed |
| `dotnet test tests/Opc.Ua.Server.Tests -f net10.0
-p:CustomTestTarget=net10.0`, on 80e516309 (before the master merge) |
8408 passed, 0 failed, 5 skipped. The run exits 1 because of a
pre-existing certificate leak in `PushComplianceRegressionTests`, which
is unrelated to this change and fixed in #4719 |
| `dotnet build src/Opc.Ua.Server -c Release` (net10.0, net9.0, net8.0,
net48) | 0 warnings |
| MinimalBoilerServer started with
`DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`, then MinimalClient (on
27c8478a9) | Before the fix: the server crashed with
`CultureNotFoundException`. After: it starts, and the client browses,
reads, and receives a subscription notification |
## Related Issues
- No tracking issue. Found while verifying the Native AOT documentation
(follow-up to #4555).
## Checklist
- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
277 lines
11 KiB
C#
277 lines
11 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Globalization;
|
|
using System.Xml;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Tests that the resource manager behaves the same when the runtime runs in
|
|
/// globalization-invariant mode, which is common for Native AOT and container
|
|
/// deployments.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The globalization mode of a process is fixed when it starts, so the tests
|
|
/// reproduce each mode with the factory that creates the culture of a locale id.
|
|
/// </remarks>
|
|
[TestFixture]
|
|
[Category("ResourceManager")]
|
|
[Parallelizable]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
public sealed class ResourceManagerInvariantGlobalizationTests
|
|
{
|
|
/// <summary>
|
|
/// The ways the runtime can create the culture of a locale id.
|
|
/// </summary>
|
|
public enum CultureMode
|
|
{
|
|
/// <summary>
|
|
/// The runtime creates every culture with its data.
|
|
/// </summary>
|
|
Normal,
|
|
|
|
/// <summary>
|
|
/// Globalization-invariant mode: the runtime creates only the invariant
|
|
/// culture and throws <see cref="CultureNotFoundException"/> for any
|
|
/// other locale id.
|
|
/// </summary>
|
|
Invariant,
|
|
|
|
/// <summary>
|
|
/// Globalization-invariant mode with predefined cultures turned off: the
|
|
/// runtime creates every culture, but with the data of the invariant
|
|
/// culture, for example the language "iv".
|
|
/// </summary>
|
|
InvariantData
|
|
}
|
|
|
|
[TestCase(CultureMode.Normal)]
|
|
[TestCase(CultureMode.Invariant)]
|
|
[TestCase(CultureMode.InvariantData)]
|
|
public void LoadDefaultTextRegistersEnglishStatusTexts(CultureMode mode)
|
|
{
|
|
using ResourceManager resourceManager = CreateResourceManager(mode);
|
|
|
|
resourceManager.LoadDefaultText();
|
|
|
|
var input = new ServiceResult(
|
|
StatusCodes.BadTimeout,
|
|
new XmlQualifiedName("BadTimeout"),
|
|
LocalizedText.Null);
|
|
LocalizedText translated = resourceManager.Translate(["en-US"], input).LocalizedText;
|
|
string[] locales = resourceManager.GetAvailableLocales();
|
|
Assert.That(locales, Has.Length.EqualTo(1));
|
|
Assert.That(locales[0], Is.EqualTo("en-US"));
|
|
Assert.That(translated.Locale, Is.EqualTo("en-US"));
|
|
Assert.That(translated.Text, Is.EqualTo("BadTimeout"));
|
|
}
|
|
|
|
[TestCase(CultureMode.Normal, "de-de", "de-DE")]
|
|
[TestCase(CultureMode.Invariant, "de-de", "de-DE")]
|
|
[TestCase(CultureMode.Normal, "sr-latn-rs", "sr-Latn-RS")]
|
|
[TestCase(CultureMode.Invariant, "sr-latn-rs", "sr-Latn-RS")]
|
|
[TestCase(CultureMode.Normal, "es-419", "es-419")]
|
|
[TestCase(CultureMode.Invariant, "es-419", "es-419")]
|
|
public void AddUsesTheCanonicalCasingOfTheLocaleId(
|
|
CultureMode mode,
|
|
string locale,
|
|
string expected)
|
|
{
|
|
using ResourceManager resourceManager = CreateResourceManager(mode);
|
|
|
|
resourceManager.Add("greeting", locale, "Hello");
|
|
|
|
LocalizedText translated = resourceManager.Translate([expected], "greeting", "Hi");
|
|
string[] locales = resourceManager.GetAvailableLocales();
|
|
Assert.That(locales, Has.Length.EqualTo(1));
|
|
Assert.That(locales[0], Is.EqualTo(expected));
|
|
Assert.That(translated.Locale, Is.EqualTo(expected));
|
|
Assert.That(translated.Text, Is.EqualTo("Hello"));
|
|
}
|
|
|
|
[TestCase(CultureMode.Normal)]
|
|
[TestCase(CultureMode.Invariant)]
|
|
[TestCase(CultureMode.InvariantData)]
|
|
public void TranslateFallsBackToAnotherRegionOfTheLanguage(CultureMode mode)
|
|
{
|
|
using ResourceManager resourceManager = CreateResourceManager(mode);
|
|
resourceManager.Add("greeting", "de-DE", "Hallo");
|
|
|
|
LocalizedText translated = resourceManager.Translate(["de-AT"], "greeting", "Hello");
|
|
|
|
Assert.That(translated.Locale, Is.EqualTo("de-DE"));
|
|
Assert.That(translated.Text, Is.EqualTo("Hallo"));
|
|
}
|
|
|
|
[TestCase(CultureMode.Normal)]
|
|
[TestCase(CultureMode.Invariant)]
|
|
[TestCase(CultureMode.InvariantData)]
|
|
public void TranslateMulLabelsRegionFallbackWithItsOwnLocale(CultureMode mode)
|
|
{
|
|
using ResourceManager resourceManager = CreateResourceManager(mode);
|
|
resourceManager.Add("greeting", "de-DE", "Hallo");
|
|
|
|
LocalizedText translated = resourceManager.Translate(
|
|
["mul", "de-AT"],
|
|
new LocalizedText("greeting", "en-US", "Hello"));
|
|
|
|
Assert.That(translated.Locale, Is.EqualTo("de-DE"));
|
|
Assert.That(translated.Text, Is.EqualTo("Hallo"));
|
|
}
|
|
|
|
[TestCase(CultureMode.Normal, "de")]
|
|
[TestCase(CultureMode.Invariant, "de")]
|
|
[TestCase(CultureMode.Normal, "zh-Hans")]
|
|
[TestCase(CultureMode.Invariant, "zh-Hans")]
|
|
[TestCase(CultureMode.Invariant, "sl-rozaj")]
|
|
[TestCase(CultureMode.Invariant, "en-a-bbb-x-c")]
|
|
public void AddRejectsNeutralLocale(CultureMode mode, string locale)
|
|
{
|
|
using ResourceManager resourceManager = CreateResourceManager(mode);
|
|
|
|
Assert.That(
|
|
() => resourceManager.Add("greeting", locale, "Hallo"),
|
|
Throws.ArgumentException);
|
|
Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
|
|
}
|
|
|
|
[TestCase(CultureMode.Normal)]
|
|
[TestCase(CultureMode.Invariant)]
|
|
[TestCase(CultureMode.InvariantData)]
|
|
public void AddRejectsMalformedLocale(CultureMode mode)
|
|
{
|
|
using ResourceManager resourceManager = CreateResourceManager(mode);
|
|
|
|
Assert.That(
|
|
() => resourceManager.Add("greeting", "!!", "Hallo"),
|
|
Throws.TypeOf<CultureNotFoundException>());
|
|
Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// In globalization-invariant mode the locale id is parsed, so a tag that does
|
|
/// not match the RFC 5646 grammar is rejected, even where it starts with a
|
|
/// language and a region.
|
|
/// </summary>
|
|
[TestCase("en-US-x")]
|
|
[TestCase("en-US-ab")]
|
|
[TestCase("en-US-ab1")]
|
|
[TestCase("en-US-u")]
|
|
[TestCase("en-US-u-a")]
|
|
[TestCase("en-US-x-abcdefghi")]
|
|
[TestCase("en-US-abcdefghi")]
|
|
[TestCase("abcd-efg")]
|
|
[TestCase("en--US")]
|
|
[TestCase("en-US-")]
|
|
public void AddRejectsMalformedLocaleInInvariantMode(string locale)
|
|
{
|
|
using ResourceManager resourceManager = CreateResourceManager(CultureMode.Invariant);
|
|
|
|
Assert.That(
|
|
() => resourceManager.Add("greeting", locale, "Hallo"),
|
|
Throws.TypeOf<CultureNotFoundException>());
|
|
Assert.That(resourceManager.GetAvailableLocales(), Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// In globalization-invariant mode a well-formed locale id with extended
|
|
/// language, variant, extension or private use subtags is registered in the
|
|
/// recommended casing of RFC 5646.
|
|
/// </summary>
|
|
[TestCase("de-ch-1996", "de-CH-1996")]
|
|
[TestCase("en-us-posix", "en-US-posix")]
|
|
[TestCase("zh-cmn-hans-cn", "zh-cmn-Hans-CN")]
|
|
[TestCase("en-us-u-ca-gregory", "en-US-u-ca-gregory")]
|
|
[TestCase("en-us-x-twain", "en-US-x-twain")]
|
|
public void AddAcceptsWellFormedLocaleInInvariantMode(string locale, string expected)
|
|
{
|
|
using ResourceManager resourceManager = CreateResourceManager(CultureMode.Invariant);
|
|
|
|
resourceManager.Add("greeting", locale, "Hello");
|
|
|
|
string[] locales = resourceManager.GetAvailableLocales();
|
|
Assert.That(locales, Has.Length.EqualTo(1));
|
|
Assert.That(locales[0], Is.EqualTo(expected));
|
|
}
|
|
|
|
private static ResourceManager CreateResourceManager(CultureMode mode)
|
|
{
|
|
var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
|
|
return mode switch
|
|
{
|
|
CultureMode.Normal => new ResourceManager(configuration),
|
|
CultureMode.Invariant => new ResourceManager(configuration, CreateInvariantModeCulture),
|
|
CultureMode.InvariantData => new ResourceManager(configuration, CreateInvariantDataCulture),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(mode))
|
|
};
|
|
}
|
|
|
|
private static CultureInfo CreateInvariantModeCulture(string name)
|
|
{
|
|
if (name.Length == 0)
|
|
{
|
|
return CultureInfo.InvariantCulture;
|
|
}
|
|
throw new CultureNotFoundException(
|
|
nameof(name),
|
|
name,
|
|
"Only the invariant culture is supported in globalization-invariant mode.");
|
|
}
|
|
|
|
private static CultureInfo CreateInvariantDataCulture(string name)
|
|
{
|
|
return name.Length == 0 ? CultureInfo.InvariantCulture : new InvariantDataCulture(name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A culture that carries the data of the invariant culture, as the runtime
|
|
/// creates it in globalization-invariant mode when predefined cultures are
|
|
/// turned off.
|
|
/// </summary>
|
|
private sealed class InvariantDataCulture : CultureInfo
|
|
{
|
|
/// <summary>
|
|
/// Creates the culture of a locale id.
|
|
/// </summary>
|
|
public InvariantDataCulture(string name)
|
|
: base(name)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override string TwoLetterISOLanguageName => "iv";
|
|
}
|
|
}
|
|
}
|