From 4241f29e2f0fad2d4df80564e8e20bd958607240 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:59:12 -0700 Subject: [PATCH] fix(buildtool): check what the runtime actually loads, and add tzdata The ICU entry was inherited from the original package-name check (libicu-dev on apt, libicu on dnf) and translated to a library probe without verifying which library that should be. Three things were wrong with it. libicui18n was never checked. The strings in libSystem.Globalization.Native.so are exactly libicuuc and libicui18n, so probe both; libicudata arrives as a dependency of libicuuc and the remaining ICU libraries are never referenced. The probe accepted any SONAME down to .so.0, but the runtime's floor is MinICUVersion 60. A host carrying only an older ICU passed the check and then FailFast'd at startup. RHEL/CentOS 7 ships ICU 50 and is affected. The ldconfig fast path bypassed the version range entirely, since a cache line for libicuuc.so.50 still matches the "libicuuc.so" prefix, and it trusted a stale cache: removing a library without refreshing the cache reported it present. Drop it and ask the loader directly. dlopen consults the same cache but answers the question we are actually asking, so this also removes the musl special-case, where ldconfig exits 0 while producing nothing usable. Add tzdata. It is data rather than a library, so no loader probe finds it, but the event scheduler resolves configured zone IDs through TimeZoneInfo and without /usr/share/zoneinfo every lookup except UTC throws. Slim containers routinely omit it. Move the rationale to dev-docs/platform-prerequisites.md so it is discoverable without reading the build tool, covering what each dependency is for, why InvariantGlobalization stays false, and the tzdata-legacy split on Debian 12 and Ubuntu 24.04 that hides aliases such as US/Eastern. README no longer asks for libicu-dev. Matching the runtime package by pattern ('^libicu[0-9]+$') is version-independent without pulling in headers, so no -dev package is required on any supported distribution. Verified on Debian and Alpine: all present, each dependency removed individually, a stale ldconfig cache, and ICU downgraded to .so.50. --- CLAUDE.md | 1 + .../Prerequisites/NativeLibraryChecker.cs | 103 +++++++----- README.md | 20 ++- dev-docs/platform-prerequisites.md | 159 ++++++++++++++++++ 4 files changed, 232 insertions(+), 51 deletions(-) create mode 100644 dev-docs/platform-prerequisites.md diff --git a/CLAUDE.md b/CLAUDE.md index 7f24661a4..924255249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Event system | `dev-docs/events.md` | | Threading model | `dev-docs/threading-model.md` | | Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) | `dev-docs/server-lifecycle.md` | +| Platform prerequisites (ICU, tzdata, native libs per distro) | `dev-docs/platform-prerequisites.md` | | Configuration system | `dev-docs/configuration.md` | | Networking & packets | `dev-docs/networking-packets.md` | | IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` | diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs index 8050f6533..95c521c24 100644 --- a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs +++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs @@ -32,9 +32,10 @@ public static class NativeLibraryChecker "Linux", [ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0", - "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN", + "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata", " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)", - "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu", + " (add tzdata-legacy if the shard is configured with an alias such as US/Eastern)", + "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu tzdata", "CentOS: Also requires epel-release and CRB enabled" ] ), @@ -179,49 +180,25 @@ public static class NativeLibraryChecker } /// - /// Libraries the server needs from the system on Linux. - /// - /// zstd is absent because ZstdNet bundles libzstd for every RID. liburing is absent because - /// IORingGroup issues io_uring syscalls directly and imports only libc, libSystem.dylib, - /// kernel32.dll, kernelbase.dll and ws2_32.dll. - /// - /// ICU is here because the server does not set InvariantGlobalization and its runtimeconfig - /// sets System.Globalization.PredefinedCulturesOnly to false, so it genuinely needs ICU. - /// - /// MaxSoVersion bounds the dlopen fallback used when ldconfig cannot answer. It is per library - /// because the SONAME digit is: libdeflate is .so.0 and libargon2 is .so.1 on the same machine, - /// while ICU tracks its own release train and was .so.74 on Ubuntu 24.04, .so.76 on Alpine and - /// .so.77 on Fedora. A single small bound silently reports ICU missing when it is installed. + /// Native libraries the server needs from the system on Linux, and the SONAME range to accept + /// for each. Rationale and per-distro package names: dev-docs/platform-prerequisites.md. /// - private static readonly (string Name, int MaxSoVersion)[] _linuxLibraries = + private static readonly (string Name, int MinSoVersion, int MaxSoVersion)[] _linuxLibraries = [ - ("libicuuc", 99), - ("libdeflate", 9), - ("libargon2", 9) + ("libicuuc", 60, 120), + ("libicui18n", 60, 120), + ("libdeflate", 0, 9), + ("libargon2", 0, 9) ]; private static List CheckLinux(PlatformInfo platform) { - // Ask whether the loader can find each library rather than whether a named package is - // installed. Package names were why the -dev packages were mandated, and no hardcoded name - // works for ICU anyway: its apt package is release-specific (libicu72, libicu74, ...). - // ldconfig -p is the loader's own cache, so matching on the "libfoo.so" prefix covers - // libdeflate.so.0, libargon2.so.1 and libicuuc.so.76 alike. - // - // It is only ever a fast *positive* signal. musl's ldconfig exits 0 while producing no - // usable cache, so trusting a negative from it reports every library missing on Alpine even - // when all of them are installed. A cache can also be stale or omit LD_LIBRARY_PATH. - // Anything it does not vouch for gets dlopen'd for real before being called missing. - var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p"); - var cache = ldResult.Success ? ldResult.StandardOutput : null; - var results = new List(); var missing = new List(); - foreach (var (name, maxSoVersion) in _linuxLibraries) + foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries) { - var found = cache?.Contains($"{name}.so", StringComparison.Ordinal) == true || - CanLoad(name, maxSoVersion); + var found = CanLoad(name, minSoVersion, maxSoVersion); if (!found) { @@ -236,6 +213,19 @@ public static class NativeLibraryChecker }); } + var hasTimeZoneData = HasTimeZoneData(); + if (!hasTimeZoneData) + { + missing.Add("tzdata"); + } + + results.Add(new PrerequisiteResult + { + Name = "tzdata", + Passed = hasTimeZoneData, + Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw" + }); + if (missing.Count == 0) { return results; @@ -258,7 +248,7 @@ public static class NativeLibraryChecker Name = "Install all missing", Passed = false, IsWarning = true, - Details = "Install the runtime libraries. The -dev/-devel packages are not required:", + Details = "Install the missing dependencies. The -dev/-devel packages are not required:", InstallCommand = BuildInstallCommand(platform, missing) }); @@ -266,18 +256,37 @@ public static class NativeLibraryChecker } /// - /// Asks the loader directly, for when ldconfig cannot answer. Mirrors the binding packages' - /// own probing: the unversioned name first, then libfoo.so.N descending. Bare names go through - /// the full loader search path, so LD_LIBRARY_PATH and /etc/ld.so.conf.d still apply. + /// tzdata is data, not a library, so no loader probe finds it. Asking the runtime rather than + /// stat'ing a path keeps TZDIR honoured, and the count is still accurate under + /// InvariantGlobalization, which this tool runs with — only display names degrade there. /// - private static bool CanLoad(string library, int maxSoVersion) + private static bool HasTimeZoneData() + { + try + { + return TimeZoneInfo.GetSystemTimeZones().Count > 1; + } + catch + { + return false; + } + } + + /// + /// Asks the loader directly rather than querying a package database or scanning ldconfig's + /// cache, both of which answer a different question and can disagree with what dlopen will do. + /// Mirrors the binding packages' own probing: the unversioned name first, then libfoo.so.N + /// descending. Bare names go through the full loader search path, so LD_LIBRARY_PATH and + /// /etc/ld.so.conf.d still apply. + /// + private static bool CanLoad(string library, int minSoVersion, int maxSoVersion) { if (TryLoadAndFree($"{library}.so")) { return true; } - for (var soVersion = maxSoVersion; soVersion >= 0; soVersion--) + for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--) { if (TryLoadAndFree($"{library}.so.{soVersion}")) { @@ -305,14 +314,17 @@ public static class NativeLibraryChecker { case PackageManager.Apt: { + // Distinct because the two ICU libraries resolve to the same package, and + // ResolveAptIcuPackage shells out, so it is memoized rather than called per name. var packages = missing.Select( library => library switch { "libdeflate" => "libdeflate0", "libargon2" => "libargon2-1", - _ => ResolveAptIcuPackage() + "tzdata" => "tzdata", + _ => _aptIcuPackage ??= ResolveAptIcuPackage() } - ); + ).Distinct(); return $"sudo apt-get install -y {string.Join(' ', packages)}"; } @@ -323,9 +335,10 @@ public static class NativeLibraryChecker { "libdeflate" => "libdeflate", "libargon2" => "libargon2", + "tzdata" => "tzdata", _ => "libicu" } - ); + ).Distinct(); return $"sudo dnf install -y {string.Join(' ', packages)}"; } @@ -334,6 +347,8 @@ public static class NativeLibraryChecker } } + private static string _aptIcuPackage; + /// /// ICU's apt package carries the ABI version in its name and there is no stable alias, so ask /// apt which one this release actually ships instead of printing a name that rots. diff --git a/README.md b/README.md index 1b76ce804..2321fe8e3 100644 --- a/README.md +++ b/README.md @@ -87,21 +87,27 @@ dnf install -y dnf-plugins-core dnf config-manager --set-enabled crb dnf install -y epel-release # Prerequisites -dnf install -y findutils libicu libdeflate libargon2 +dnf install -y findutils libicu libdeflate libargon2 tzdata ``` ### Ubuntu, Debian, etc ```shell apt-get update -y -apt-get install -y libicu-dev libdeflate0 libargon2-1 +# The ICU runtime package carries the ABI version in its name (libicu74, libicu76, …) and has no +# stable alias, so match it by pattern rather than pinning a release-specific name. +apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata ``` -Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. ICU is the exception -on Debian and Ubuntu, where the runtime package carries the ABI version in its name (`libicu74`, -`libicu76`, …) and there is no stable alias, so `libicu-dev` is the version-independent way to pull -it in. Run `./build-tool --check-prereqs` to print the exact packages your release needs. +Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. Run +`./build-tool --check-prereqs` to check the current machine and print the exact packages your +release needs. -`zstd` is no longer listed because ZstdNet bundles `libzstd` for every platform. +`zstd` is not listed because ZstdNet bundles `libzstd` for every platform, and `liburing` is not +listed because IORingGroup issues `io_uring` syscalls directly. + +If the shard's configured time zone is a legacy alias such as `US/Eastern`, Debian 12 and Ubuntu +24.04 also need `tzdata-legacy`. See [Platform Prerequisites](dev-docs/platform-prerequisites.md) +for what each dependency is for and what breaks without it. ## OSX Requirements ```shell diff --git a/dev-docs/platform-prerequisites.md b/dev-docs/platform-prerequisites.md new file mode 100644 index 000000000..3e76d3b47 --- /dev/null +++ b/dev-docs/platform-prerequisites.md @@ -0,0 +1,159 @@ +# Platform Prerequisites + +OS-level dependencies ModernUO needs at runtime, why each one is required, and what breaks without +it. This page is about software packages, not hardware sizing. + +Run `./build-tool --check-prereqs` from the repository root to check the current machine. It prints +the exact install command for the detected distribution. + +## What is required + +| Dependency | Platform | Why | +|---|---|---| +| .NET 10 Runtime | all | — | +| ICU (`libicuuc`, `libicui18n`) | Linux, macOS | The runtime refuses to start without it; see below | +| tzdata | Linux | Time zone lookups; see below | +| `libdeflate` | all | `LibDeflate.Bindings` | +| `libargon2` | all | `Argon2.Bindings` (password hashing) | +| VC++ Redistributable v14 | Windows | Native bindings | + +Not required, despite appearances: + +- **zstd** — `ZstdNet` bundles `libzstd` for every RID. +- **liburing** — `IORingGroup` issues `io_uring` syscalls directly. It imports only `libc`, + `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll` and `ws2_32.dll`. +- **`-dev` / `-devel` packages** — see "Runtime packages only" below. + +## Install + +```sh +# Debian / Ubuntu (ICU has no stable package alias, so match it by pattern) +sudo apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata + +# Fedora / RHEL +sudo dnf install -y libdeflate libargon2 libicu tzdata + +# Alpine +apk add --no-cache libdeflate argon2-libs icu-libs tzdata + +# macOS +brew install icu4c libdeflate argon2 +``` + +CentOS additionally needs EPEL and CRB: + +```sh +sudo dnf install -y epel-release epel-next-release && sudo dnf config-manager --set-enabled crb +``` + +## Runtime packages only + +Only the runtime packages are needed. The `-dev`/`-devel` packages are **not** required. + +They used to be, because .NET's `DllImport` probing looks for the unversioned `libfoo.so`, and on +Linux that bare symlink ships only in the development package. The runtime package ships the +versioned SONAME (`libdeflate.so.0`, `libargon2.so.1`). The binding packages now probe the versioned +names as well, so the runtime package is sufficient. + +Anything still documenting `libicu-dev` or `libdeflate-dev` as a requirement is out of date. + +## ICU + +`Directory.Build.props` sets `InvariantGlobalization=false`, so ICU is mandatory. Without it the +runtime does **not** throw — it `FailFast`s: + +``` +Couldn't find a valid ICU package installed on the system. Please install libicu (or icu-libs) +using your package manager and try again. +``` + +That is `SIGABRT` (exit 134) and it cannot be caught. Note the process **starts cleanly and aborts +later**, at whatever line first touches a culture, so the crash rarely points at the cause. + +### Why invariant mode is not an option + +`InvariantGlobalization=true` would remove the ICU dependency, but it changes behaviour in ways that +corrupt data silently. Measured on .NET 10 with the repository's settings: + +| Behaviour | With ICU | Invariant mode | +|---|---|---| +| `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data | +| de-DE decimal separator | `,` | `.` | +| `1234.5` as de-DE | `1.234,5` | `1,234.5` | +| `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) | +| sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` | +| `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` | +| UTF-8 round-trip of non-ASCII | unaffected | unaffected | + +The dangerous row is the first. Because `Directory.Build.props` also sets +`PredefinedCulturesOnly=false`, constructing a culture in invariant mode **succeeds** instead of +throwing `CultureNotFoundException`, and hands back an object populated with invariant data. Number +parsing and formatting then produce wrong values with no error, and culture-sensitive sort order +silently becomes ordinal. + +Encoding is not affected — UTF-8 round-trips correctly in both modes. + +### Version floor + +The runtime accepts `libicuuc.so.60` and above (`MinICUVersion` in `pal_icushim.c`). The prerequisite +checker enforces the same floor, so a host carrying only an older ICU is reported missing rather than +passing and then aborting at startup. RHEL/CentOS 7 ships ICU 50 and is affected. + +ICU tracks its own release train, so the SONAME digit varies widely by distribution — `.so.74` on +Ubuntu 24.04, `.so.76` on Alpine, `.so.77` on Fedora, `.so.78` on openSUSE. There is no stable +package alias on Debian and Ubuntu, which is why the checker resolves the name via `apt-cache` +instead of hardcoding one. + +Only `libicuuc` and `libicui18n` are used; those are the two names +`libSystem.Globalization.Native.so` loads. `libicudata` arrives as a dependency of `libicuuc`, and +`libicuio`/`libicutu`/`libicutest` are never referenced. Every distribution ships all of them in a +single package, so installing ICU at all satisfies both. + +## tzdata + +The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads +`/usr/share/zoneinfo` on Linux. This is separate from ICU: it is data, not a library, so no loader +probe finds it, and slim container images routinely omit it. + +Without tzdata every lookup except `UTC` throws: + +``` +TimeZoneNotFoundException: The time zone ID 'America/New_York' was not found on the local computer. +``` + +`TimeZoneInfo.GetSystemTimeZones()` returns 1 entry instead of ~419, and `TimeZoneInfo.Local` falls +back to UTC. + +### There is no per-zone subset + +Distributions do not package individual zones — it is one `tzdata` package, about 2 MB installed for +the full set. Subsetting is not worth pursuing. + +The one split that does exist is **`tzdata-legacy`** on Debian 12 and Ubuntu 24.04, which carries the +deprecated aliases. With plain `tzdata` alone: + +| Zone ID | `tzdata` | `tzdata-legacy` | +|---|---|---| +| `America/New_York` | present | — | +| `Europe/Kyiv` | present | — | +| `EST5EDT` | present | — | +| `US/Eastern` | **missing** | present | +| `Asia/Calcutta` | **missing** | present | + +So a shard configured with a legacy alias such as `US/Eastern` throws on a current Debian or Ubuntu +even though tzdata is installed. Either install `tzdata-legacy` or switch the configured value to the +canonical ID (`America/New_York`, `Asia/Kolkata`). + +`TZDIR` is honoured if the data lives somewhere non-standard. + +## How the check works + +`--check-prereqs` asks the loader directly — `NativeLibrary.TryLoad` on the unversioned name, then +`libfoo.so.N` descending through the accepted range. + +It deliberately does not consult a package database or `ldconfig -p`. Both answer a different +question than "will `dlopen` succeed": + +- Package queries need a hardcoded name, which does not exist for ICU. +- `ldconfig`'s cache can be stale, omits `LD_LIBRARY_PATH`, and carries no version information to + enforce the ICU floor against. On musl it exits successfully while producing nothing usable.