Keep ICU symbols out of non-Intl binaries in the zero-cost gate

ICU builds with symbol renaming on, so its C API symbols carry the _78
version suffix, its data symbol is icudt78_dat, and its C++ symbols are
Itanium manglings that name namespace icu_78. icuSymbols() classifies
exactly those, and the gate's dynamic-tier check now fails a candidate
that links any of them without the pattern being allowed: a program that
does not lower to Intl links no ICU object at all.

The engine-free Intl functions (scr_intl_num_format_en_us in scr_lib.c,
scr_intl_segmenter_new in scr_grapheme.c) share the scr_intl_ prefix and
are deliberately not part of the test; any scr_intl.c function that does
link brings an ICU symbol with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 19:49:11 -07:00
co-authored by Claude Opus 5.5
parent 995eb54dbf
commit 7cf37d9dc9
3 changed files with 93 additions and 0 deletions
+32
View File
@@ -393,6 +393,38 @@ export function dynamicTierSymbols(names) {
* program without a call into Node.js may define any of these. */
export const NODE_ISLAND_PREFIXES = ["scr_node_island", "scr_loop_set_node_island"];
/** The namespace ICU's own symbols live in after its version-suffixed
* renaming: `icu_78`, which Itanium mangling spells `6icu_78`. */
export const ICU_CPP_NAMESPACE = "6icu_78";
/** The ICU archive: no program that answers its Intl forms without
* ICU — that is, every program whose IR has no `icu.*` libCall — may define
* a symbol from it. The test is exact because ICU builds with symbol
* renaming on: every C API symbol carries the `_78` version suffix
* (`ubrk_open_78`), its data symbol is `icudt78_dat`, and every C++ symbol
* is an Itanium mangling that names namespace `icu_78`, which the mangling
* spells `6icu_78` wherever the name occurs — as the function's own
* namespace (`_ZN6icu_78...`), in its parameter types, in vtables, typeinfo
* and guard variables, or in a libc++ instantiation over an ICU type. No
* symbol outside the archive carries either mark.
*
* scr_intl.c's own functions are deliberately not tested by name. The
* engine-free Intl paths share their prefix (`scr_intl_num_format_en_us` in
* scr_lib.c, `scr_intl_segmenter_new` in scr_grapheme.c), and they must stay
* linkable without ICU (I4). Any scr_intl.c function that does link brings an
* ICU symbol with it, which the test catches. The C++ standard library
* merged into the archive cannot leak on its own either: an archive
* contributes a member only when something references it, and only ICU's
* objects reference libc++. */
export function icuSymbols(names) {
return [...new Set(names)]
.filter(
(name) =>
name.endsWith("_78") || name.includes(ICU_CPP_NAMESPACE) || name.startsWith("icudt78"),
)
.sort();
}
/** The Node island symbols among `names`, sorted. */
export function nodeIslandSymbols(names) {
return [...new Set(names)]
+5
View File
@@ -48,6 +48,7 @@ import {
linkedRuntimeFiles,
llvmFunctionHashes,
median,
icuSymbols,
nodeIslandSymbols,
nmOutput,
parseNm,
@@ -379,6 +380,10 @@ function dynamicTierCheck(program, baseline, candidate, artifacts, allowed) {
const nodeIsland = nodeIslandSymbols(definedFunctions(candidate.exe));
if (nodeIsland.length > 0 && !allowed)
failures.push(`links Node island symbols: ${nodeIsland.slice(0, 5).join(", ")}`);
// Intl over ICU is linked only into programs whose IR lowers to it. A
// program that legitimately calls it names the pattern in --allow.
const icu = icuSymbols(definedFunctions(candidate.exe));
if (icu.length > 0 && !allowed) failures.push(`links ICU symbols: ${icu.slice(0, 5).join(", ")}`);
if (typed && after.length > 0)
failures.push(
`fully typed program links dynamic-tier symbols: ${after.slice(0, 5).join(", ")}`,
@@ -0,0 +1,56 @@
// The symbol classifier the zero-cost gate uses to prove that a program which
// does not use Intl links no part of the ICU archive. The samples
// are the shapes `nm` printed for the archive-linked binaries: ICU's C API
// with its `_78` version suffix, its data symbol, and the several Itanium
// manglings of names in namespace icu_78 — own namespace, parameters only,
// internal linkage, guard variables, vtables and typeinfo.
import { describe, expect, it } from "vitest";
import { icuSymbols } from "../../scripts/zero-cost-compare.mjs";
const ICU_SYMBOLS = [
"ubrk_open_78",
"unum_open_78",
"u_strToUTF8_78",
"icudt78_dat",
"_ZN6icu_7813UnicodeString6appendEPKDsi",
"_ZNK6icu_7824DateTimePatternGenerator17getDateTimeFormatE16UDateFormatStyleR10UErrorCode",
"_Z24ulocimp_toLanguageTag_78PKcRN6icu_788ByteSinkEbR10UErrorCode",
"_ZL10createPathPKciS0_iS0_RN6icu_7810CharStringEP10UErrorCode",
"_ZGVZN6icu_7823IslamicUmalquraCalendar19handleComputeFieldsEiR10UErrorCodeE14kUmalquraStart",
"_ZTWN6icu_7810CharStringE",
"_ZTVN6icu_7813UnicodeStringE",
"_ZTIN6icu_7813UnicodeStringE",
"_ZTSN6icu_7813UnicodeStringE",
// A libc++ instantiation over an ICU type exists only inside the archive.
"_ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEE6appendERN6icu_7810CharStringE",
];
// The engine-free Intl paths share the `scr_intl_` prefix and must stay
// linkable without ICU (grapheme segmentation, the en-US number fast path),
// so the classifier does not key on it.
const NON_ICU_SYMBOLS = [
"main",
"scr_intl_num_format_en_us",
"scr_intl_segmenter_new",
"scr_intl_segmenter_refusing",
"scr_dyn_call",
"scr_string_concat",
];
describe("icuSymbols", () => {
it("classifies every ICU archive symbol", () => {
expect(icuSymbols(ICU_SYMBOLS)).toEqual([...ICU_SYMBOLS].sort());
});
it("leaves the engine-free runtime alone", () => {
expect(icuSymbols(NON_ICU_SYMBOLS)).toEqual([]);
});
it("deduplicates and sorts", () => {
expect(icuSymbols(["ubrk_close_78", "main", "ubrk_close_78", "icudt78_dat"])).toEqual([
"icudt78_dat",
"ubrk_close_78",
]);
});
});