Let servers start in globalization-invariant mode

A server did not start in globalization-invariant mode, which Native AOT
and container images often enable with InvariantGlobalization. When the
server loaded its default status texts, ResourceManager created
CultureInfo("en-US"), and the runtime throws CultureNotFoundException for
every culture but the invariant one in this mode.

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 of a
well-formed locale id, the id is parsed into the casing of its culture
instead, and still rejected if it names a language without a region. A
malformed locale id is rejected as before. The resolved locale ids are
cached, so loading the default texts creates each culture once.

The tests reproduce both invariant-mode behaviors of the runtime: no
culture at all, and cultures that carry invariant data. Verified by
starting MinimalBoilerServer with DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
and connecting MinimalClient (browse, read, subscribe).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marc
2026-10-09 10:03:26 +02:00
parent 7fe7794a19
commit 80e516309f
3 changed files with 453 additions and 33 deletions
+5 -5
View File
@@ -99,11 +99,11 @@ publish reports for your code. The following features are AOT-compatible:
In your own code, avoid unbounded reflection and runtime code generation; see
[Keep code AOT-compatible](#3-keep-code-aot-compatible).
Do not enable globalization-invariant mode for a server, a common size
optimization for native executables. With the `InvariantGlobalization` property
or the `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT` environment variable set, the
server does not start: it reports a `CultureNotFoundException` for the `en-US`
culture. Clients run in this mode.
Clients and servers run in globalization-invariant mode, which the
`InvariantGlobalization` property enables to make native executables smaller.
In this mode a server still selects translations by locale id and falls back to
another region of the same language. Arguments in translated texts, such as
numbers and dates, use the invariant format for every locale.
## Test harness overview
+220 -28
View File
@@ -30,6 +30,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Threading;
using System.Xml;
#if !NET6_0_OR_GREATER
@@ -47,12 +48,27 @@ namespace Opc.Ua.Server
/// Initializes the resource manager with the server instance that owns it.
/// </summary>
public ResourceManager(ApplicationConfiguration configuration)
: this(configuration, static locale => new CultureInfo(locale))
{
}
/// <summary>
/// Initializes the resource manager with the factory that creates the
/// culture of a locale id.
/// </summary>
/// <param name="configuration">The configuration of the server.</param>
/// <param name="createCulture">Creates the culture of a locale id, or throws
/// <see cref="CultureNotFoundException"/> when the runtime cannot create it.</param>
internal ResourceManager(
ApplicationConfiguration configuration,
Func<string, CultureInfo> createCulture)
{
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}
m_createCulture = createCulture ?? throw new ArgumentNullException(nameof(createCulture));
m_translationTables = [];
}
@@ -205,7 +221,7 @@ namespace Opc.Ua.Server
for (int ii = 0; ii < m_translationTables.Count; ii++)
{
availableLocales[ii] = m_translationTables![ii].Locale!.Name;
availableLocales[ii] = m_translationTables[ii].Locale;
}
return availableLocales;
@@ -234,9 +250,9 @@ namespace Opc.Ua.Server
throw new ArgumentNullException(nameof(text));
}
var culture = new CultureInfo(locale);
string localeId = GetLocaleId(locale, out bool isNeutral);
if (culture.IsNeutralCulture)
if (isNeutral)
{
throw new ArgumentException(
"Cannot specify neutral locales for translation tables.",
@@ -245,8 +261,8 @@ namespace Opc.Ua.Server
lock (m_lock)
{
TranslationTable? table = GetTable(culture.Name);
table!.Translations[key] = text;
TranslationTable table = GetTable(localeId);
table.Translations[key] = text;
}
}
@@ -267,9 +283,9 @@ namespace Opc.Ua.Server
throw new ArgumentNullException(nameof(translations));
}
var culture = new CultureInfo(locale);
string localeId = GetLocaleId(locale, out bool isNeutral);
if (culture.IsNeutralCulture)
if (isNeutral)
{
throw new ArgumentException(
"Cannot specify neutral locales for translation tables.",
@@ -278,11 +294,11 @@ namespace Opc.Ua.Server
lock (m_lock)
{
TranslationTable? table = GetTable(culture.Name);
TranslationTable table = GetTable(localeId);
foreach (KeyValuePair<string, string> translation in translations)
{
table!.Translations[translation.Key] = translation.Value;
table.Translations[translation.Key] = translation.Value;
}
}
}
@@ -415,7 +431,7 @@ namespace Opc.Ua.Server
if (table.Translations
.TryGetValue((info.Key ?? info.Text)!, out string? translation))
{
translations[table!.Locale!.Name] = translation!;
translations[table.Locale] = translation!;
}
}
}
@@ -430,13 +446,13 @@ namespace Opc.Ua.Server
string? translation = FindBestTranslation(
preferredLocales.Slice(i, 1),
(info.Key ?? info.Text)!,
out CultureInfo culture);
out string locale);
if (translation != null)
{
// label the text with the locale of the table it came
// from, which may be another region of the requested
// language.
translations[culture.Name] = translation;
translations[locale] = translation;
}
}
}
@@ -450,15 +466,15 @@ namespace Opc.Ua.Server
else
{
// find the best translation.
string? translatedText = info.Text;
CultureInfo culture = CultureInfo.InvariantCulture;
string? translatedText;
string locale;
lock (m_lock)
{
translatedText = FindBestTranslation(
preferredLocales,
(info.Key ?? info.Text)!,
out culture);
out locale);
// use the default if no translation available.
if (translatedText == null)
@@ -470,23 +486,44 @@ namespace Opc.Ua.Server
}
// construct translated localized text.
return new LocalizedText(culture.Name, translatedText, info);
return new LocalizedText(locale, translatedText, info);
}
}
/// <summary>
/// Stores the translations for a locale.
/// </summary>
private class TranslationTable
private sealed class TranslationTable
{
public CultureInfo? Locale;
public SortedDictionary<string, string> Translations = [];
/// <summary>
/// Creates an empty table for a locale id.
/// </summary>
public TranslationTable(string locale)
{
Locale = locale;
Language = GetLanguage(locale);
}
/// <summary>
/// The locale id in the casing of its culture.
/// </summary>
public string Locale { get; }
/// <summary>
/// The language of the locale id, used to fall back to another region.
/// </summary>
public string Language { get; }
/// <summary>
/// The translations by key.
/// </summary>
public SortedDictionary<string, string> Translations { get; } = [];
}
/// <summary>
/// Finds the translation table for the locale. Creates a new table if it does not exist.
/// </summary>
private TranslationTable? GetTable(string locale)
private TranslationTable GetTable(string locale)
{
lock (m_lock)
{
@@ -495,20 +532,172 @@ namespace Opc.Ua.Server
{
TranslationTable translationTable = m_translationTables[ii];
if (translationTable!.Locale!.Name == locale)
if (translationTable.Locale == locale)
{
return translationTable;
}
}
// add table.
var table = new TranslationTable { Locale = new CultureInfo(locale) };
var table = new TranslationTable(locale);
m_translationTables.Add(table);
return table;
}
}
/// <summary>
/// Returns the locale id of a translation table in the casing of its culture,
/// and whether the locale id names a language without a region.
/// </summary>
/// <remarks>
/// When the runtime cannot create the culture, as in globalization-invariant
/// mode, where it creates no culture other than the invariant culture, a
/// well-formed locale id is parsed instead.
/// </remarks>
/// <exception cref="CultureNotFoundException">The locale id is malformed.</exception>
private string GetLocaleId(string locale, out bool isNeutral)
{
lock (m_lock)
{
if (m_localeIds.TryGetValue(locale, out (string LocaleId, bool IsNeutral) cached))
{
isNeutral = cached.IsNeutral;
return cached.LocaleId;
}
string localeId;
try
{
CultureInfo culture = m_createCulture(locale);
localeId = culture.Name;
isNeutral = culture.IsNeutralCulture;
}
catch (CultureNotFoundException) when (TryParseLocaleId(locale, out localeId, out isNeutral))
{
// the runtime cannot create the culture of a well-formed locale id.
}
m_localeIds[locale] = (localeId, isNeutral);
return localeId;
}
}
/// <summary>
/// Parses a well-formed locale id (an RFC 5646 language tag) into the casing
/// of its culture, and returns whether it names a language without a region.
/// </summary>
private static bool TryParseLocaleId(string locale, out string localeId, out bool isNeutral)
{
localeId = string.Empty;
isNeutral = false;
string[] subtags = locale.Split('-');
string language = subtags[0];
if (language.Length is < 2 or > 8 || !IsAsciiLetters(language))
{
return false;
}
var builder = new StringBuilder(locale.Length);
builder.Append(language.ToLowerInvariant());
// the script and the region can only follow the language, in this order.
int regionIndex = 1;
bool hasRegion = false;
bool isExtension = false;
for (int ii = 1; ii < subtags.Length; ii++)
{
string subtag = subtags[ii];
if (subtag.Length is < 1 or > 8 || !IsAsciiLettersOrDigits(subtag))
{
return false;
}
builder.Append('-');
// a singleton starts an extension or a private use sequence.
isExtension |= subtag.Length == 1;
if (!isExtension && ii == 1 && subtag.Length == 4 && IsAsciiLetters(subtag))
{
builder.Append(char.ToUpperInvariant(subtag[0]))
.Append(subtag[1..].ToLowerInvariant());
regionIndex = 2;
}
else if (!isExtension &&
ii == regionIndex &&
((subtag.Length == 2 && IsAsciiLetters(subtag)) ||
(subtag.Length == 3 && IsAsciiDigits(subtag))))
{
builder.Append(subtag.ToUpperInvariant());
hasRegion = true;
}
else
{
builder.Append(subtag.ToLowerInvariant());
}
}
localeId = builder.ToString();
isNeutral = !hasRegion;
return true;
}
/// <summary>
/// Returns the language subtag of a locale id.
/// </summary>
private static string GetLanguage(string locale)
{
int index = locale.IndexOf('-', StringComparison.Ordinal);
return index == -1 ? locale : locale[..index];
}
/// <summary>
/// Returns true if the value contains only ASCII letters.
/// </summary>
private static bool IsAsciiLetters(string value)
{
foreach (char c in value)
{
if (c is not ((>= 'a' and <= 'z') or (>= 'A' and <= 'Z')))
{
return false;
}
}
return true;
}
/// <summary>
/// Returns true if the value contains only ASCII digits.
/// </summary>
private static bool IsAsciiDigits(string value)
{
foreach (char c in value)
{
if (c is not (>= '0' and <= '9'))
{
return false;
}
}
return true;
}
/// <summary>
/// Returns true if the value contains only ASCII letters and digits.
/// </summary>
private static bool IsAsciiLettersOrDigits(string value)
{
foreach (char c in value)
{
if (c is not ((>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9')))
{
return false;
}
}
return true;
}
/// <summary>
/// Removes the null (unknown) entries from the requested locales.
/// </summary>
@@ -572,9 +761,9 @@ namespace Opc.Ua.Server
private string? FindBestTranslation(
ArrayOf<string> preferredLocales,
string key,
out CultureInfo culture)
out string locale)
{
culture = null!;
locale = string.Empty;
TranslationTable? match = null;
if (preferredLocales.Count == 0)
@@ -608,24 +797,24 @@ namespace Opc.Ua.Server
// all done if exact match found (locale ids are case-insensitive, RFC 5646).
if (string.Equals(
translationTable!.Locale!.Name,
translationTable.Locale,
preferredLocales[jj],
StringComparison.OrdinalIgnoreCase) &&
translationTable.Translations.TryGetValue(key, out string? exactMatch))
{
culture = translationTable.Locale;
locale = translationTable.Locale;
return exactMatch;
}
// check for matching language but different region.
if (match == null &&
string.Equals(
translationTable.Locale.TwoLetterISOLanguageName,
translationTable.Language,
language,
StringComparison.OrdinalIgnoreCase) &&
translationTable.Translations.TryGetValue(key, out translatedText))
{
culture = translationTable.Locale;
locale = translationTable.Locale;
match = translationTable;
}
}
@@ -705,6 +894,9 @@ namespace Opc.Ua.Server
}
private readonly Lock m_lock = new();
private readonly Func<string, CultureInfo> m_createCulture;
private readonly Dictionary<string, (string LocaleId, bool IsNeutral)> m_localeIds =
new(StringComparer.Ordinal);
private readonly List<TranslationTable> m_translationTables;
private Dictionary<StatusCode, TranslationInfo>? m_statusCodeMapping;
private Dictionary<XmlQualifiedName, TranslationInfo>? m_symbolicIdMapping;
@@ -0,0 +1,228 @@
/* ========================================================================
* 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 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")]
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);
}
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";
}
}
}