Probe versioned SONAMEs so runtime-only Linux installs resolve - #4
Merged
Merged
Conversation
DllImport never asks for a versioned SONAME, so libdeflate.so.0 sitting
in /usr/lib went unfound and the -dev package's unversioned symlink was
the only thing making resolution work. Probe libdeflate.so.9 down to
.so.0 by bare name after the unversioned lookup fails -- bare names go
through the full loader search path, so LD_LIBRARY_PATH and
/etc/ld.so.conf.d still apply.
The unversioned lookup stays ahead of the probe, so installs that
already have -dev resolve exactly as before.
Also names the runtime package in the failure message. The old text
("Ensure libdeflate is installed on your system") did not tell an
operator what to type, or that -dev is not what they need.
Verified in containers with only the runtime package installed:
Debian trixie, Ubuntu 24.04 and Alpine (musl). Both are .so.0 on all
three; argon2 is .so.1 on the same machines, which is why the version
is probed rather than hardcoded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repo had no CI, so nothing would have caught the versioned-SONAME regression this branch fixes. Five jobs. Three are the resolution matrix: runtime package only (the bug), -dev installed (existing servers must keep working), and no native library at all (the failure message must name the runtime package). Plus Alpine for musl, and Windows for the bundled DLL path. linux-runtime-only asserts the unversioned symlink is absent before running. Without that, a base image that started shipping the symlink would resolve on the plain-name step and the job would keep passing while testing nothing. linux-missing is filtered to NativeResolutionTests: LibDeflateTests holds a static LibDeflateBinding, so its type initializer throws when the library is absent, which is correct rather than a failure. Bumps to 1.0.4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kamronbatman
added a commit
to modernuo/ModernUO
that referenced
this pull request
Aug 7, 2026
## Why `IORingGroup` issues io_uring syscalls directly rather than linking `liburing`, so the package has never been needed — but we ask operators to install it in the README, install it in CI, and check for it in `build-tool`. Verified against the **shipped** `IORingGroup` 1.0.9 assembly, not just the source: | Symbol | Occurrences in `IORingGroup.dll` | |---|---| | `libc`, `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll`, `ws2_32.dll` | present | | `liburing` | **0** | | `io_uring_queue_init` — liburing's entry point | **0** | | `io_uring_setup` — the raw syscall | 1 | If it linked liburing it would call `io_uring_queue_init` / `io_uring_submit`. It calls neither. ## What changes Nine lines across three files, removing `liburing-dev` / `liburing-devel` from: - `README.md` — both the dnf and apt prerequisite blocks - `.github/workflows/build-test.yml` — both install steps - `Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs` — the cross-compile target text, the apt and dnf package lists, and the `ldconfig` fallback map Nothing else is touched. `zstd` and the `-dev` packages are a separate discussion and a separate PR. ## Risk None to the build. `liburing` was only ever installed, never linked or loaded — removing it cannot change resolution behaviour. `build-tool` builds clean. This was found while investigating why Linux requires `-dev` packages at all; that fix lives in the binding packages (modernuo/LibDeflate.Bindings#4, modernuo/Argon2.Bindings#13) and lands separately once those publish. This piece is independent and unblocked, hence its own PR.
kamronbatman
added a commit
to modernuo/ModernUO
that referenced
this pull request
Aug 7, 2026
… the way the runtime does (#2561) ## Why ModernUO mandated `-dev` packages on production servers for exactly one reason: `DllImport` never asks for a versioned SONAME, so `libdeflate.so.0` and `libargon2.so.1` sitting in `/usr/lib` went unfound, and the `-dev` package's unversioned symlink was the only thing making resolution work. The `-dev` packages ship no library of their own — operators were installing headers and a static lib on machines that compile nothing. Fixed in the binding packages (modernuo/LibDeflate.Bindings#4, modernuo/Argon2.Bindings#13), so this picks them up and stops asking. ``` LibDeflate.Bindings 1.0.3 -> 1.0.4 Argon2.Bindings 1.17.0 -> 1.19.0 ``` ## zstd is dropped too, on every platform ZstdNet bundles `libzstd` for `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64` and win, and nothing shells out to the CLI. Verified: the 15 `ManagedArchive` round-trip tests pass in a container with no `zstd` package installed and `which zstd` empty. Removed from the README, the macOS `brew install`, and CI — so the macOS runners now prove it rather than us assuming it. ## NativeLibraryChecker asks a different question It asked *"is package X installed"* via `dpkg -l` / `rpm -q`. That is what forced `-dev`, and no hardcoded name works for ICU anyway — its apt package is release-specific (`libicu70` on Ubuntu 22.04, `libicu76` on Debian 13). It now asks *"will the loader find this"*: `NativeLibrary.TryLoad` on the unversioned name, then `libfoo.so.N` descending through the range the runtime accepts. It deliberately does not consult a package database or `ldconfig -p`. Both answer a different question than "will `dlopen` succeed" — see the ICU section below for how that bit. ## What was wrong with the ICU check `libicuuc` was **inherited, not derived**. It came from translating the old package-name check into a library probe, without establishing which library that should be. Reviewing it turned up three defects, all of which could report ICU present on a host where the runtime then refuses to start: - **`libicui18n` was never probed.** The only ICU names in `libSystem.Globalization.Native.so` are `libicuuc` and `libicui18n`. `libicudata` arrives as a dependency of `libicuuc`, and `libicuio`/`libicutu`/`libicutest` are never referenced — so that is the complete list, and both are checked now. - **No version floor.** The runtime's `MinICUVersion` is 60, but the probe accepted down to `.so.0`. RHEL/CentOS 7 ships ICU 50, which passed and then aborted at startup. - **The `ldconfig` fast path bypassed the range.** A cache line for `libicuuc.so.50` still matches a `libicuuc.so` prefix test, so the floor was unenforceable through it. It also trusts a stale cache — observed reporting a deleted `libdeflate` as present. Removed in favour of asking the loader directly, which reads the same cache but answers the real question, and which also deletes the musl special-case (`ldconfig -p` exits 0 on musl while producing nothing usable). Worth knowing when this goes wrong in the field: **missing ICU does not throw, it `FailFast`s** — SIGABRT, exit 134, uncatchable. The process starts cleanly and dies later at whatever line first touches a culture, so the stack rarely implicates ICU. ## tzdata is a separate prerequisite, and nothing was checking it The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads `/usr/share/zoneinfo`. It is data rather than a library, so no loader probe finds it, and slim container images routinely omit it. Without it every lookup except `UTC` throws `TimeZoneNotFoundException` and `GetSystemTimeZones()` returns 1 entry instead of ~419. There is no per-zone packaging to opt into — it is ~2 MB for the whole set. The one split that does exist is a trap rather than an optimization: Debian 12 and Ubuntu 24.04 move the legacy aliases into `tzdata-legacy`, so plain `tzdata` has `America/New_York` and `EST5EDT` but is **missing `US/Eastern` and `Asia/Calcutta`**. A shard configured with a legacy alias throws even though tzdata is installed. Documented, with both fixes. ## Why `InvariantGlobalization` stays false Dropping ICU entirely by turning on invariant mode looks tempting and is not safe. Because `Directory.Build.props` also sets `PredefinedCulturesOnly=false`, invariant mode does **not** throw `CultureNotFoundException` — it silently hands back invariant data. Measured on .NET 10: | 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 | Number parsing and formatting produce wrong values with no error, and culture-sensitive sort order silently becomes ordinal. Encoding is not the mechanism — UTF-8 round-trips fine either way. ## Documentation The rationale now lives in `dev-docs/platform-prerequisites.md` rather than in comments, so it is discoverable without reading the build tool: what each dependency is for, what breaks without it, per-distro package names, the ICU floor, the `tzdata-legacy` split, and why the check asks the loader instead of the package manager. README drops `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** — which was the point of the whole change. ## CI now proves the claim instead of contradicting it The dnf job already installed runtime packages only. The apt job installed `libicu-dev`, which ships the unversioned `libicuuc.so` symlink — so every probe succeeded on the first attempt and the versioned-SONAME fallback this PR depends on was never exercised. Switched to the pattern match, verified to resolve exactly one package on jammy (70), bookworm (72), noble (74) and trixie (76). Added an assertion that the unversioned symlinks are absent. Without it the suite silently stops testing anything the moment a base image starts shipping one. Verified against all eight matrix distributions — none ship them — and confirmed the step fails as intended when a symlink is planted. ## Audit of every other native entry point Checked whether anything else has the same hazard. It does not: | Import | Verdict | |---|---| | `ws2_32.dll` — `SocketHelper` | Always present on Windows | | `libc` — `SocketHelper` | **Verified safe**, see below | | ZstdNet → `libzstd` | Bundled for every RID | | IORingGroup | No native library; raw syscalls | | ICU | Loaded by the .NET runtime itself, which probes versioned suffixes | `libc` deserved a hard look, because `libc.so` *is* a `libc6-dev` linker script while the real library is `libc.so.6` — the same shape as the bug being fixed. It is not affected. Measured in a container with no `libc6-dev`: ``` /usr/lib/x86_64-linux-gnu/libc.so ABSENT /lib/x86_64-linux-gnu/libc.so.6 present TryLoad("libc") LOADED <- resolves where "libdeflate" would not TryLoad("libc.so") not found getpid() -> DllImport("libc") WORKS ``` Confirmed on Alpine/musl as well. No code in this repo registers a `DllImportResolver`, and nothing else P/Invokes. ## `--check-prereqs` New flag. `Program.cs` only ran the SDK check in non-interactive mode — `NativeLibraryChecker` was reachable only through the Spectre-driven guided flow, so there was no way to verify a deployment target from a script or a container. It is what made the container verification below possible, and it prints the exact ICU package for the running release via `apt-cache`. It renders through the same `PrerequisiteChecker` the guided menu uses, rather than a second hand-rolled table that could drift from it. Spectre drops ANSI styling on its own when stdout is not a terminal, so redirected output stays clean; the console width is widened in that case so the install hints, which are shell commands meant to be copied, do not gain a newline mid-command. ``` ╭───────────────────────────╮ │ Checking native libraries │ ╰───────────────────────────╯ ✔ libicuuc (Found) ✔ libicui18n (Found) ❌ libdeflate (Not found) ❌ tzdata (Not found — every zone except UTC will throw)⚠️ Install the missing dependencies. The -dev/-devel packages are not required: sudo apt-get install -y libicu74 libdeflate0 tzdata ``` Exit code carries the machine-readable half: 0 when everything resolves, 1 when anything is missing. ## Verification Against 1.0.4 and 1.19.0: build plus **810 Server.Tests and 642 UOContent.Tests**, on Windows and on Linux with **only** `libdeflate0` and `libargon2-1` installed — with the absence of the unversioned symlink asserted first so the run could not pass for the wrong reason. `--check-prereqs` verified in containers on Debian and Alpine across every state that matters: all present, each dependency removed individually, tzdata removed, a deliberately stale `ldconfig` cache, and ICU downgraded to `.so.50` to confirm the floor rejects it. Package resolution and the absence of unversioned symlinks checked on all eight CI distributions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
DllImport("libdeflate")fails on a Linux box wherelibdeflate.so.0is sitting in/usr/lib/x86_64-linux-gnu/, because .NET's probing never tries a versioned SONAME. Installinglibdeflate-dev"fixes" it purely by adding an unversioned.sosymlink to the same file — the-devpackage ships no library of its own, so this costs production servers a build-toolinginstall for a symlink.
Hardcoding
libdeflate.so.0is not the answer: the digit is per library and per distro. libdeflateis
.so.0and argon2 is.so.1on the same machine.The fix
One new step in the resolver that already exists. After the unversioned
libdeflate.solookupfails, probe
libdeflate.so.Nfor N = 9 down to 0. Probing by bare name matters — it goesthrough the full loader search path, so
LD_LIBRARY_PATHand/etc/ld.so.conf.dstill apply,which globbing fixed directories would not.
Nothing is bundled for Linux. libdeflate selects SIMD paths at compile time, so a bundled
.soiseither a generic-baseline build slower than the distro's, or a per-distro/per-hardware build matrix
someone has to maintain.
The unversioned lookup stays ahead of the versioned probe, so installs that already have
-devresolve exactly as before. There is a CI job asserting that.
Resolution order is now:
Step 5 moved after step 3 — the loader's own path should win over a hardcoded directory. This also
matches
Argon2.Bindings, which had the two in the opposite order for no reason.The failure message is part of the fix
Since this deliberately relies on the operator installing a runtime package, the exception has to
say which one. It went from
Could not load libdeflate. Ensure libdeflate is installed on your system.to naming the package per distro family, listing what was tried, and stating outright that-devis not required.CI
The repo had no CI, so nothing would have caught this or a regression of it. Five jobs: runtime
package only (the bug),
-devinstalled (existing servers keep working), no native library at all(the message is asserted), Alpine for musl, and Windows for the bundled DLL.
linux-runtime-onlyasserts the unversioned symlink is absent before running. Without that, abase image that started shipping the symlink would resolve on step 3 and the job would keep passing
while testing nothing.
linux-missingis filtered toNativeResolutionTests:LibDeflateTestsholds astatic readonly LibDeflateBinding, so its type initializer throws when the library is absent —correct behaviour, not a failure to report.
Verification
Run locally in containers with only the runtime package installed, symlink absence confirmed:
Ubuntu 24.04, Fedora, and Alpine (musl) pass. RED was demonstrated first — before this change the
same container fails with
DllNotFoundExceptionwhilelibdeflate.so.0sits right there.Windows unchanged.
Bumps to 1.0.4.
🤖 Generated with Claude Code