Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/ResourceManagerInvariantGlobalizationTests.cs
Marc Schier 9600f0de9c Let servers start in globalization-invariant mode (#4716)
# 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>
2026-10-09 15:17:12 +02:00

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";
}
}
}