From d4be3cf6cca7eeb3f93921642a8ae58657cfebd9 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Mon, 29 Jun 2026 14:43:14 +0100 Subject: [PATCH 1/4] fix(intl): discover the installed ICU version at runtime instead of a hardcoded max The Linux ICU loader looped a fixed `ICU_VERSION_MAX = 76 downto 70` range, so a newer ICU runtime (77+, e.g. on Ubuntu 25.10) was never found: `libicui18n.so` failed to load, all Intl returned empty, and DateTimeFormat fell back to printing raw numbers. Supporting a new ICU required bumping the constant by hand. Replace the hardcoded ceiling with runtime discovery: scan the standard library directories for the installed `libicui18n.so.` files, take the newest major present, and try it (down to a `ICU_VERSION_MIN` compatibility floor), keeping the unversioned-SONAME fallback. There is no upper bound, so any future ICU release is picked up with no code change. LoadLibrary still resolves the final path through the dynamic linker; the scan only learns which majors exist. Adds source/shared/ICU.Test.pas covering the version parsing and the newest-present directory scan, including majors above the old 76 ceiling. Co-Authored-By: Claude Opus 4.8 --- source/shared/ICU.Test.pas | 115 ++++++++++++++++++++++++++++++++ source/shared/ICU.pas | 132 +++++++++++++++++++++++++++++++------ 2 files changed, 226 insertions(+), 21 deletions(-) create mode 100644 source/shared/ICU.Test.pas diff --git a/source/shared/ICU.Test.pas b/source/shared/ICU.Test.pas new file mode 100644 index 000000000..fe4c89f4f --- /dev/null +++ b/source/shared/ICU.Test.pas @@ -0,0 +1,115 @@ +{ Unit tests for the runtime ICU version discovery in source/shared/ICU.pas. + + The Linux loader must pick up whatever ICU major is installed — including + versions newer than any the engine has seen before — without a code change. + These tests pin the version parsing and the "newest present" directory scan + that make that work, including majors above the old hard-coded 76 ceiling. + + The discovery helpers are Linux-only; on other platforms ICU loads by a single + fixed library name and there is nothing to discover, so the suite is a no-op. } + +program ICU.Test; + +{$I Shared.inc} + +uses + SysUtils, + + ICU, + TestingPascalLibrary; + +type + TICUTests = class(TTestSuite) + private + {$IFDEF LINUX} + procedure TestParseMajorVersion; + procedure TestDiscoverHighestInDirHasNoCap; + {$ELSE} + procedure TestDiscoveryIsLinuxOnly; + {$ENDIF} + public + procedure SetupTests; override; + end; + +{$IFDEF LINUX} +procedure TouchFile(const APath: string); +var + Handle: THandle; +begin + Handle := FileCreate(APath); + if Handle <> THandle(-1) then + FileClose(Handle); +end; +{$ENDIF} + +procedure TICUTests.SetupTests; +begin + {$IFDEF LINUX} + Test('ParseICUSoMajorVersion extracts the major from a versioned SONAME', + TestParseMajorVersion); + Test('HighestICUMajorVersionInDir returns the newest present major, uncapped', + TestDiscoverHighestInDirHasNoCap); + {$ELSE} + Test('ICU version discovery is Linux-only (no-op on this platform)', + TestDiscoveryIsLinuxOnly); + {$ENDIF} +end; + +{$IFDEF LINUX} +procedure TICUTests.TestParseMajorVersion; +begin + Expect(ParseICUSoMajorVersion('libicui18n.so.77', 'libicui18n.so')).ToBe(77); + Expect(ParseICUSoMajorVersion('libicui18n.so.100', 'libicui18n.so')).ToBe(100); + Expect(ParseICUSoMajorVersion('libicui18n.so.76.1', 'libicui18n.so')).ToBe(76); + Expect(ParseICUSoMajorVersion('libicui18n.so.70', 'libicui18n.so')).ToBe(70); + // No numeric version, or an unrelated SONAME, yields 0. + Expect(ParseICUSoMajorVersion('libicui18n.so', 'libicui18n.so')).ToBe(0); + Expect(ParseICUSoMajorVersion('libicui18n.so.', 'libicui18n.so')).ToBe(0); + Expect(ParseICUSoMajorVersion('libicui18n.so.x', 'libicui18n.so')).ToBe(0); + Expect(ParseICUSoMajorVersion('libicuuc.so.76', 'libicui18n.so')).ToBe(0); +end; + +procedure TICUTests.TestDiscoverHighestInDirHasNoCap; +var + Dir: string; +begin + Dir := GetTempFileName(GetTempDir, 'gicu'); + DeleteFile(Dir); + ForceDirectories(Dir); + try + // No ICU library present. + Expect(HighestICUMajorVersionInDir(Dir)).ToBe(0); + + // A spread of majors, including ones above the old hard-coded 76 ceiling. + TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.74'); + TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.76'); + TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.77'); + TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.100'); + // A non-i18n SONAME and an unrelated file must be ignored. + TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicuuc.so.100'); + TouchFile(IncludeTrailingPathDelimiter(Dir) + 'unrelated.txt'); + + Expect(HighestICUMajorVersionInDir(Dir)).ToBe(100); + finally + DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.74'); + DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.76'); + DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.77'); + DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.100'); + DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicuuc.so.100'); + DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'unrelated.txt'); + RemoveDir(Dir); + end; +end; +{$ELSE} +procedure TICUTests.TestDiscoveryIsLinuxOnly; +begin + Expect(ICULibraryAvailable or True).ToBe(True); +end; +{$ENDIF} + +begin + TestRunnerProgram.AddSuite(TICUTests.Create('ICU')); + TestRunnerProgram.Run; + + ExitCode := TestResultToExitCode; +end. diff --git a/source/shared/ICU.pas b/source/shared/ICU.pas index 6113abaf0..221b52ad7 100644 --- a/source/shared/ICU.pas +++ b/source/shared/ICU.pas @@ -11,6 +11,16 @@ function TryGetICULibraryHandle(out AHandle: TLibHandle): Boolean; function ICULibraryAvailable: Boolean; function ICUGetProcAddress(const AName: string): Pointer; +{$IFDEF LINUX} +{ Exposed for unit tests of the runtime ICU version discovery. + ParseICUSoMajorVersion extracts the major from a versioned SONAME + ('libicui18n.so.77' -> 77; 'libicui18n.so.76.1' -> 76; unversioned/garbage -> 0). + HighestICUMajorVersionInDir returns the newest ICU major whose i18n library is + present in ADir, or 0 when none is found. } +function ParseICUSoMajorVersion(const AFileName, ABase: string): Integer; +function HighestICUMajorVersionInDir(const ADir: string): Integer; +{$ENDIF} + implementation uses @@ -27,7 +37,10 @@ implementation {$IFDEF LINUX} ICU_I18N_BASE = 'libicui18n.so'; ICU_UC_BASE = 'libicuuc.so'; - ICU_VERSION_MAX = 76; + // Oldest ICU major that still exports the symbols the engine resolves. There is + // deliberately NO maximum: the newest installed major is discovered at runtime + // (DiscoverHighestICUMajorVersion), so a newer ICU — 77 and beyond — is picked + // up with no code change. ICU_VERSION_MIN = 70; {$ENDIF} @@ -42,37 +55,111 @@ implementation {$ENDIF} {$IFDEF LINUX} -function TryLoadLinuxICU(out AHandle: TLibHandle): Boolean; +const + // Standard locations distributions install the versioned ICU runtime into, + // across the Linux targets the project builds (Debian/Ubuntu multiarch for + // x86_64 and aarch64, plus the generic lib dirs other distros use). These are + // only scanned to learn which ICU majors are present; LoadLibrary still + // resolves the final path through the dynamic linker, and a non-existent dir + // is simply skipped, so listing both arch triplets is harmless. + ICU_SCAN_DIRS: array[0..6] of string = ( + '/usr/lib/x86_64-linux-gnu', '/usr/lib/aarch64-linux-gnu', + '/lib/x86_64-linux-gnu', '/lib/aarch64-linux-gnu', + '/usr/lib64', '/usr/lib', '/usr/local/lib'); + +function ParseICUSoMajorVersion(const AFileName, ABase: string): Integer; +var + Prefix, Digits: string; + Index: Integer; +begin + Result := 0; + Prefix := ABase + '.'; + if Copy(AFileName, 1, Length(Prefix)) <> Prefix then + Exit; + Digits := ''; + Index := Length(Prefix) + 1; + while (Index <= Length(AFileName)) and + (AFileName[Index] >= '0') and (AFileName[Index] <= '9') do + begin + Digits := Digits + AFileName[Index]; + Inc(Index); + end; + if Digits <> '' then + Result := StrToIntDef(Digits, 0); +end; + +function HighestICUMajorVersionInDir(const ADir: string): Integer; +var + SearchRec: TSearchRec; + Major: Integer; +begin + Result := 0; + if FindFirst(IncludeTrailingPathDelimiter(ADir) + ICU_I18N_BASE + '.*', + faAnyFile, SearchRec) = 0 then + try + repeat + Major := ParseICUSoMajorVersion(SearchRec.Name, ICU_I18N_BASE); + if Major > Result then + Result := Major; + until FindNext(SearchRec) <> 0; + finally + FindClose(SearchRec); + end; +end; + +function DiscoverHighestICUMajorVersion: Integer; +var + DirIndex, Major: Integer; +begin + Result := 0; + for DirIndex := Low(ICU_SCAN_DIRS) to High(ICU_SCAN_DIRS) do + begin + Major := HighestICUMajorVersionInDir(ICU_SCAN_DIRS[DirIndex]); + if Major > Result then + Result := Major; + end; +end; + +function TryLoadVersionedICU(AVersion: Integer; out AHandle: TLibHandle): Boolean; var - Version: Integer; - LibName, UCLibName: string; + UC: TLibHandle; begin Result := False; + AHandle := LoadLibrary(ICU_I18N_BASE + '.' + IntToStr(AVersion)); + if AHandle = NilHandle then + Exit; + UC := LoadLibrary(ICU_UC_BASE + '.' + IntToStr(AVersion)); + if UC = NilHandle then + begin + UnloadLibrary(AHandle); + AHandle := NilHandle; + Exit; + end; + UCHandle := UC; + ICUVersionSuffix := '_' + IntToStr(AVersion); + Result := True; +end; + +function TryLoadLinuxICU(out AHandle: TLibHandle): Boolean; +var + Version, Highest: Integer; +begin AHandle := NilHandle; UCHandle := NilHandle; - for Version := ICU_VERSION_MAX downto ICU_VERSION_MIN do - begin - AHandle := NilHandle; - UCHandle := NilHandle; - LibName := ICU_I18N_BASE + '.' + IntToStr(Version); - UCLibName := ICU_UC_BASE + '.' + IntToStr(Version); - AHandle := LoadLibrary(LibName); - if AHandle <> NilHandle then + // Try the newest installed ICU first, then any older co-installed majors down + // to the compatibility floor. The ceiling is whatever is installed, so newer + // releases (77+) need no code change. + Highest := DiscoverHighestICUMajorVersion; + for Version := Highest downto ICU_VERSION_MIN do + if TryLoadVersionedICU(Version, AHandle) then begin - UCHandle := LoadLibrary(UCLibName); - if UCHandle = NilHandle then - begin - UnloadLibrary(AHandle); - AHandle := NilHandle; - Continue; - end; - ICUVersionSuffix := '_' + IntToStr(Version); Result := True; Exit; end; - end; + // Fallback: an unversioned SONAME (a -dev symlink, or a toolchain that keeps + // unversioned exported symbols). AHandle := LoadLibrary(ICU_I18N_BASE); if AHandle <> NilHandle then begin @@ -86,8 +173,11 @@ function TryLoadLinuxICU(out AHandle: TLibHandle): Boolean; begin ICUVersionSuffix := ''; Result := True; + Exit; end; end; + + Result := False; end; {$ENDIF} From ec2c15c775ac70a56c2334c6a649682342109e4b Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Mon, 29 Jun 2026 15:51:26 +0100 Subject: [PATCH 2/4] refactor(intl): make ICU version-discovery helpers platform-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version parsing and "newest present" directory scan are pure string and directory logic, but they were declared inside the Linux {$IFDEF} alongside the loader, so ICU.Test could only exercise them on Linux — even though the logic they test works anywhere. Move ParseICUSoMajorVersion and HighestICUMajorVersionInDir out of the Linux block and parameterise the scan by SONAME base, leaving only the actual library loading (system-path scan + LoadLibrary) Linux-only. The directory scan now enumerates entries and lets the parser decide what matches, rather than relying on FindFirst wildcard semantics that differ across platforms. ICU.Test drops its {$IFDEF LINUX} gating and runs on every platform. Co-Authored-By: Claude Opus 4.8 --- source/shared/ICU.Test.pas | 60 ++++++++++++++------------------------ source/shared/ICU.pas | 58 +++++++++++++++++++----------------- 2 files changed, 54 insertions(+), 64 deletions(-) diff --git a/source/shared/ICU.Test.pas b/source/shared/ICU.Test.pas index fe4c89f4f..bf2133c46 100644 --- a/source/shared/ICU.Test.pas +++ b/source/shared/ICU.Test.pas @@ -1,12 +1,11 @@ -{ Unit tests for the runtime ICU version discovery in source/shared/ICU.pas. +{ Unit tests for the platform-independent ICU version discovery helpers in + source/shared/ICU.pas. The Linux loader must pick up whatever ICU major is installed — including - versions newer than any the engine has seen before — without a code change. - These tests pin the version parsing and the "newest present" directory scan - that make that work, including majors above the old hard-coded 76 ceiling. - - The discovery helpers are Linux-only; on other platforms ICU loads by a single - fixed library name and there is nothing to discover, so the suite is a no-op. } + versions newer than any the engine has seen before — with no code change. The + version parsing and the "newest present" directory scan are pure string and + directory logic with no platform dependency, so they run on every platform and + are pinned here, including majors above the old hard-coded 76 ceiling. } program ICU.Test; @@ -18,20 +17,18 @@ ICU, TestingPascalLibrary; +const + I18N_BASE = 'libicui18n.so'; + type TICUTests = class(TTestSuite) private - {$IFDEF LINUX} procedure TestParseMajorVersion; - procedure TestDiscoverHighestInDirHasNoCap; - {$ELSE} - procedure TestDiscoveryIsLinuxOnly; - {$ENDIF} + procedure TestHighestInDirHasNoCap; public procedure SetupTests; override; end; -{$IFDEF LINUX} procedure TouchFile(const APath: string); var Handle: THandle; @@ -40,36 +37,29 @@ procedure TouchFile(const APath: string); if Handle <> THandle(-1) then FileClose(Handle); end; -{$ENDIF} procedure TICUTests.SetupTests; begin - {$IFDEF LINUX} Test('ParseICUSoMajorVersion extracts the major from a versioned SONAME', TestParseMajorVersion); Test('HighestICUMajorVersionInDir returns the newest present major, uncapped', - TestDiscoverHighestInDirHasNoCap); - {$ELSE} - Test('ICU version discovery is Linux-only (no-op on this platform)', - TestDiscoveryIsLinuxOnly); - {$ENDIF} + TestHighestInDirHasNoCap); end; -{$IFDEF LINUX} procedure TICUTests.TestParseMajorVersion; begin - Expect(ParseICUSoMajorVersion('libicui18n.so.77', 'libicui18n.so')).ToBe(77); - Expect(ParseICUSoMajorVersion('libicui18n.so.100', 'libicui18n.so')).ToBe(100); - Expect(ParseICUSoMajorVersion('libicui18n.so.76.1', 'libicui18n.so')).ToBe(76); - Expect(ParseICUSoMajorVersion('libicui18n.so.70', 'libicui18n.so')).ToBe(70); + Expect(ParseICUSoMajorVersion('libicui18n.so.77', I18N_BASE)).ToBe(77); + Expect(ParseICUSoMajorVersion('libicui18n.so.100', I18N_BASE)).ToBe(100); + Expect(ParseICUSoMajorVersion('libicui18n.so.76.1', I18N_BASE)).ToBe(76); + Expect(ParseICUSoMajorVersion('libicui18n.so.70', I18N_BASE)).ToBe(70); // No numeric version, or an unrelated SONAME, yields 0. - Expect(ParseICUSoMajorVersion('libicui18n.so', 'libicui18n.so')).ToBe(0); - Expect(ParseICUSoMajorVersion('libicui18n.so.', 'libicui18n.so')).ToBe(0); - Expect(ParseICUSoMajorVersion('libicui18n.so.x', 'libicui18n.so')).ToBe(0); - Expect(ParseICUSoMajorVersion('libicuuc.so.76', 'libicui18n.so')).ToBe(0); + Expect(ParseICUSoMajorVersion('libicui18n.so', I18N_BASE)).ToBe(0); + Expect(ParseICUSoMajorVersion('libicui18n.so.', I18N_BASE)).ToBe(0); + Expect(ParseICUSoMajorVersion('libicui18n.so.x', I18N_BASE)).ToBe(0); + Expect(ParseICUSoMajorVersion('libicuuc.so.76', I18N_BASE)).ToBe(0); end; -procedure TICUTests.TestDiscoverHighestInDirHasNoCap; +procedure TICUTests.TestHighestInDirHasNoCap; var Dir: string; begin @@ -78,7 +68,7 @@ procedure TICUTests.TestDiscoverHighestInDirHasNoCap; ForceDirectories(Dir); try // No ICU library present. - Expect(HighestICUMajorVersionInDir(Dir)).ToBe(0); + Expect(HighestICUMajorVersionInDir(Dir, I18N_BASE)).ToBe(0); // A spread of majors, including ones above the old hard-coded 76 ceiling. TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.74'); @@ -89,7 +79,7 @@ procedure TICUTests.TestDiscoverHighestInDirHasNoCap; TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicuuc.so.100'); TouchFile(IncludeTrailingPathDelimiter(Dir) + 'unrelated.txt'); - Expect(HighestICUMajorVersionInDir(Dir)).ToBe(100); + Expect(HighestICUMajorVersionInDir(Dir, I18N_BASE)).ToBe(100); finally DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.74'); DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.76'); @@ -100,12 +90,6 @@ procedure TICUTests.TestDiscoverHighestInDirHasNoCap; RemoveDir(Dir); end; end; -{$ELSE} -procedure TICUTests.TestDiscoveryIsLinuxOnly; -begin - Expect(ICULibraryAvailable or True).ToBe(True); -end; -{$ENDIF} begin TestRunnerProgram.AddSuite(TICUTests.Create('ICU')); diff --git a/source/shared/ICU.pas b/source/shared/ICU.pas index 221b52ad7..b5551b2db 100644 --- a/source/shared/ICU.pas +++ b/source/shared/ICU.pas @@ -11,15 +11,15 @@ function TryGetICULibraryHandle(out AHandle: TLibHandle): Boolean; function ICULibraryAvailable: Boolean; function ICUGetProcAddress(const AName: string): Pointer; -{$IFDEF LINUX} -{ Exposed for unit tests of the runtime ICU version discovery. +{ Platform-independent helpers behind the runtime ICU version discovery (the + library loading that uses them is Linux-only). They are pure string/directory + logic, so they are available — and unit-tested — on every platform. ParseICUSoMajorVersion extracts the major from a versioned SONAME - ('libicui18n.so.77' -> 77; 'libicui18n.so.76.1' -> 76; unversioned/garbage -> 0). - HighestICUMajorVersionInDir returns the newest ICU major whose i18n library is - present in ADir, or 0 when none is found. } + ('libicui18n.so.77' -> 77; '...so.76.1' -> 76; unversioned/garbage -> 0). + HighestICUMajorVersionInDir returns the newest major among '.' + files in ADir, or 0 when none is found. } function ParseICUSoMajorVersion(const AFileName, ABase: string): Integer; -function HighestICUMajorVersionInDir(const ADir: string): Integer; -{$ENDIF} +function HighestICUMajorVersionInDir(const ADir, ABase: string): Integer; implementation @@ -54,19 +54,6 @@ implementation ICUVersionSuffix: string; {$ENDIF} -{$IFDEF LINUX} -const - // Standard locations distributions install the versioned ICU runtime into, - // across the Linux targets the project builds (Debian/Ubuntu multiarch for - // x86_64 and aarch64, plus the generic lib dirs other distros use). These are - // only scanned to learn which ICU majors are present; LoadLibrary still - // resolves the final path through the dynamic linker, and a non-existent dir - // is simply skipped, so listing both arch triplets is harmless. - ICU_SCAN_DIRS: array[0..6] of string = ( - '/usr/lib/x86_64-linux-gnu', '/usr/lib/aarch64-linux-gnu', - '/lib/x86_64-linux-gnu', '/lib/aarch64-linux-gnu', - '/usr/lib64', '/usr/lib', '/usr/local/lib'); - function ParseICUSoMajorVersion(const AFileName, ABase: string): Integer; var Prefix, Digits: string; @@ -88,25 +75,44 @@ function ParseICUSoMajorVersion(const AFileName, ABase: string): Integer; Result := StrToIntDef(Digits, 0); end; -function HighestICUMajorVersionInDir(const ADir: string): Integer; +function HighestICUMajorVersionInDir(const ADir, ABase: string): Integer; var SearchRec: TSearchRec; Major: Integer; begin + // Scan every entry and let ParseICUSoMajorVersion decide what matches, rather + // than rely on FindFirst wildcard semantics (which differ across platforms), so + // the helper behaves identically everywhere it is tested. Result := 0; - if FindFirst(IncludeTrailingPathDelimiter(ADir) + ICU_I18N_BASE + '.*', + if FindFirst(IncludeTrailingPathDelimiter(ADir) + AllFilesMask, faAnyFile, SearchRec) = 0 then try repeat - Major := ParseICUSoMajorVersion(SearchRec.Name, ICU_I18N_BASE); - if Major > Result then - Result := Major; + if (SearchRec.Attr and faDirectory) = 0 then + begin + Major := ParseICUSoMajorVersion(SearchRec.Name, ABase); + if Major > Result then + Result := Major; + end; until FindNext(SearchRec) <> 0; finally FindClose(SearchRec); end; end; +{$IFDEF LINUX} +const + // Standard locations distributions install the versioned ICU runtime into, + // across the Linux targets the project builds (Debian/Ubuntu multiarch for + // x86_64 and aarch64, plus the generic lib dirs other distros use). These are + // only scanned to learn which ICU majors are present; LoadLibrary still + // resolves the final path through the dynamic linker, and a non-existent dir + // is simply skipped, so listing both arch triplets is harmless. + ICU_SCAN_DIRS: array[0..6] of string = ( + '/usr/lib/x86_64-linux-gnu', '/usr/lib/aarch64-linux-gnu', + '/lib/x86_64-linux-gnu', '/lib/aarch64-linux-gnu', + '/usr/lib64', '/usr/lib', '/usr/local/lib'); + function DiscoverHighestICUMajorVersion: Integer; var DirIndex, Major: Integer; @@ -114,7 +120,7 @@ function DiscoverHighestICUMajorVersion: Integer; Result := 0; for DirIndex := Low(ICU_SCAN_DIRS) to High(ICU_SCAN_DIRS) do begin - Major := HighestICUMajorVersionInDir(ICU_SCAN_DIRS[DirIndex]); + Major := HighestICUMajorVersionInDir(ICU_SCAN_DIRS[DirIndex], ICU_I18N_BASE); if Major > Result then Result := Major; end; From 5a7f32b845facbde6cc9c8547e0ea96336b5fac0 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Mon, 29 Jun 2026 16:33:11 +0100 Subject: [PATCH 3/4] fix(intl): also discover ICU through LD_LIBRARY_PATH directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: DiscoverHighestICUMajorVersion only scanned the fixed ICU_SCAN_DIRS, so an ICU reachable only through a dynamic-linker env path (LD_LIBRARY_PATH) was never found — its versioned SONAME was never tried, and the unversioned fallback does not cover a runtime-only `libicu*.so.`. The previous range-loop happened to cover this because LoadLibrary searches linker paths by SONAME; the discovery rewrite lost it. Add a generic, cross-platform HighestICUMajorVersionInDirList that scans a separator-delimited directory list, and feed it LD_LIBRARY_PATH from the Linux discovery. The newest major found there is then loaded by SONAME through the linker as before. Verified end-to-end on x86_64 Linux: with ICU moved out of all standard dirs, Intl is unavailable without LD_LIBRARY_PATH and works with it. Also address the test-fixture review: ICU.Test now fails fast — TouchFile raises when FileCreate fails and directory creation goes through a MakeTempDir helper that raises on ForceDirectories failure — so a fixture I/O error reports the real problem instead of a misleading assertion. Adds a cross-platform test for the new directory-list scan. Co-Authored-By: Claude Opus 4.8 --- source/shared/ICU.Test.pas | 52 +++++++++++++++++++++++++++++++------- source/shared/ICU.pas | 44 +++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/source/shared/ICU.Test.pas b/source/shared/ICU.Test.pas index bf2133c46..b7f111649 100644 --- a/source/shared/ICU.Test.pas +++ b/source/shared/ICU.Test.pas @@ -2,10 +2,11 @@ source/shared/ICU.pas. The Linux loader must pick up whatever ICU major is installed — including - versions newer than any the engine has seen before — with no code change. The - version parsing and the "newest present" directory scan are pure string and - directory logic with no platform dependency, so they run on every platform and - are pinned here, including majors above the old hard-coded 76 ceiling. } + versions newer than any the engine has seen before, and ICU reachable only via + LD_LIBRARY_PATH — with no code change. The version parsing and the directory + scans are pure string and directory logic with no platform dependency, so they + run on every platform and are pinned here, including majors above the old + hard-coded 76 ceiling. } program ICU.Test; @@ -25,6 +26,7 @@ TICUTests = class(TTestSuite) private procedure TestParseMajorVersion; procedure TestHighestInDirHasNoCap; + procedure TestHighestInDirListAcrossPaths; public procedure SetupTests; override; end; @@ -34,8 +36,17 @@ procedure TouchFile(const APath: string); Handle: THandle; begin Handle := FileCreate(APath); - if Handle <> THandle(-1) then - FileClose(Handle); + if Handle = THandle(-1) then + raise Exception.CreateFmt('Failed to create test fixture file: %s', [APath]); + FileClose(Handle); +end; + +function MakeTempDir(const APrefix: string): string; +begin + Result := GetTempFileName(GetTempDir, APrefix); + DeleteFile(Result); + if not ForceDirectories(Result) then + raise Exception.CreateFmt('Failed to create test fixture directory: %s', [Result]); end; procedure TICUTests.SetupTests; @@ -44,6 +55,8 @@ procedure TICUTests.SetupTests; TestParseMajorVersion); Test('HighestICUMajorVersionInDir returns the newest present major, uncapped', TestHighestInDirHasNoCap); + Test('HighestICUMajorVersionInDirList scans every dir and skips empty segments', + TestHighestInDirListAcrossPaths); end; procedure TICUTests.TestParseMajorVersion; @@ -63,9 +76,7 @@ procedure TICUTests.TestHighestInDirHasNoCap; var Dir: string; begin - Dir := GetTempFileName(GetTempDir, 'gicu'); - DeleteFile(Dir); - ForceDirectories(Dir); + Dir := MakeTempDir('gicu'); try // No ICU library present. Expect(HighestICUMajorVersionInDir(Dir, I18N_BASE)).ToBe(0); @@ -91,6 +102,29 @@ procedure TICUTests.TestHighestInDirHasNoCap; end; end; +procedure TICUTests.TestHighestInDirListAcrossPaths; +var + DirA, DirB: string; +begin + DirA := MakeTempDir('gicua'); + DirB := MakeTempDir('gicub'); + try + TouchFile(IncludeTrailingPathDelimiter(DirA) + 'libicui18n.so.76'); + TouchFile(IncludeTrailingPathDelimiter(DirB) + 'libicui18n.so.99'); + + // Newest across the whole list; the trailing empty segment is skipped. + Expect(HighestICUMajorVersionInDirList( + DirA + ':' + DirB + ':', ':', I18N_BASE)).ToBe(99); + // An empty list discovers nothing. + Expect(HighestICUMajorVersionInDirList('', ':', I18N_BASE)).ToBe(0); + finally + DeleteFile(IncludeTrailingPathDelimiter(DirA) + 'libicui18n.so.76'); + DeleteFile(IncludeTrailingPathDelimiter(DirB) + 'libicui18n.so.99'); + RemoveDir(DirA); + RemoveDir(DirB); + end; +end; + begin TestRunnerProgram.AddSuite(TICUTests.Create('ICU')); TestRunnerProgram.Run; diff --git a/source/shared/ICU.pas b/source/shared/ICU.pas index b5551b2db..762b6cf2d 100644 --- a/source/shared/ICU.pas +++ b/source/shared/ICU.pas @@ -17,9 +17,13 @@ function ICUGetProcAddress(const AName: string): Pointer; ParseICUSoMajorVersion extracts the major from a versioned SONAME ('libicui18n.so.77' -> 77; '...so.76.1' -> 76; unversioned/garbage -> 0). HighestICUMajorVersionInDir returns the newest major among '.' - files in ADir, or 0 when none is found. } + files in ADir, or 0 when none is found. HighestICUMajorVersionInDirList does the + same across a separator-delimited directory list (e.g. the entries of + LD_LIBRARY_PATH), skipping empty segments. } function ParseICUSoMajorVersion(const AFileName, ABase: string): Integer; function HighestICUMajorVersionInDir(const ADir, ABase: string): Integer; +function HighestICUMajorVersionInDirList(const ADirList: string; ASeparator: Char; + const ABase: string): Integer; implementation @@ -100,6 +104,36 @@ function HighestICUMajorVersionInDir(const ADir, ABase: string): Integer; end; end; +function HighestICUMajorVersionInDirList(const ADirList: string; ASeparator: Char; + const ABase: string): Integer; +var + Rest, Dir: string; + SepPos, Major: Integer; +begin + Result := 0; + Rest := ADirList; + while Rest <> '' do + begin + SepPos := Pos(ASeparator, Rest); + if SepPos > 0 then + begin + Dir := Copy(Rest, 1, SepPos - 1); + Delete(Rest, 1, SepPos); + end + else + begin + Dir := Rest; + Rest := ''; + end; + if Dir <> '' then + begin + Major := HighestICUMajorVersionInDir(Dir, ABase); + if Major > Result then + Result := Major; + end; + end; +end; + {$IFDEF LINUX} const // Standard locations distributions install the versioned ICU runtime into, @@ -118,12 +152,20 @@ function DiscoverHighestICUMajorVersion: Integer; DirIndex, Major: Integer; begin Result := 0; + // Standard system locations. for DirIndex := Low(ICU_SCAN_DIRS) to High(ICU_SCAN_DIRS) do begin Major := HighestICUMajorVersionInDir(ICU_SCAN_DIRS[DirIndex], ICU_I18N_BASE); if Major > Result then Result := Major; end; + // Directories the dynamic linker also searches via LD_LIBRARY_PATH, so an ICU + // reachable only through an env override is still discovered — LoadLibrary then + // resolves the chosen major by SONAME through the linker. + Major := HighestICUMajorVersionInDirList( + GetEnvironmentVariable('LD_LIBRARY_PATH'), ':', ICU_I18N_BASE); + if Major > Result then + Result := Major; end; function TryLoadVersionedICU(AVersion: Integer; out AHandle: TLibHandle): Boolean; From acec0e39d6238dfd630aafc5661fe8fea2456c44 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Mon, 29 Jun 2026 17:22:51 +0100 Subject: [PATCH 4/4] test(intl): use a path-safe separator in the ICU dir-list fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: TestHighestInDirListAcrossPaths built the list with ':' over absolute temp paths, which on Windows contain a drive-letter colon (C:\...) and would split there, breaking the test before it exercises the multi-directory logic. Use '|' — which cannot occur in a generated path — as the fixture separator. HighestICUMajorVersionInDirList takes the separator as a parameter, so the split logic under test is identical; the production caller still passes ':' for LD_LIBRARY_PATH. Co-Authored-By: Claude Opus 4.8 --- source/shared/ICU.Test.pas | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/shared/ICU.Test.pas b/source/shared/ICU.Test.pas index b7f111649..ef518b0e3 100644 --- a/source/shared/ICU.Test.pas +++ b/source/shared/ICU.Test.pas @@ -112,11 +112,15 @@ procedure TICUTests.TestHighestInDirListAcrossPaths; TouchFile(IncludeTrailingPathDelimiter(DirA) + 'libicui18n.so.76'); TouchFile(IncludeTrailingPathDelimiter(DirB) + 'libicui18n.so.99'); - // Newest across the whole list; the trailing empty segment is skipped. + // Use '|' as the list separator, not ':'. The generated temp paths are + // absolute and on Windows contain a drive-letter colon, which a ':' + // separator would wrongly split. The production caller passes ':' for + // LD_LIBRARY_PATH; the separator is a parameter, so the split logic under + // test is identical either way. Expect(HighestICUMajorVersionInDirList( - DirA + ':' + DirB + ':', ':', I18N_BASE)).ToBe(99); + DirA + '|' + DirB + '|', '|', I18N_BASE)).ToBe(99); // An empty list discovers nothing. - Expect(HighestICUMajorVersionInDirList('', ':', I18N_BASE)).ToBe(0); + Expect(HighestICUMajorVersionInDirList('', '|', I18N_BASE)).ToBe(0); finally DeleteFile(IncludeTrailingPathDelimiter(DirA) + 'libicui18n.so.76'); DeleteFile(IncludeTrailingPathDelimiter(DirB) + 'libicui18n.so.99');