Skip to content

Polylinker no-gaps gate: classify every flag, route by target not host, prove coverage before reld becomes soldr's default #123

Description

@zackees

Summary

soldr wants reld as its default linker (zackees/soldr#3262). That was tried, shipped, and reverted as premature (zackees/soldr#3263) pending #121. #121 was closed by 8420e6c1 ("escalate silently-ignored flags to lld"). An adversarial re-audit of that commit, of the full native option inventory against ld.lld's, of what real drivers emit, and of input-side semantics shows the prerequisite is not met, and that the current routing model cannot prove it is met. This issue replaces #121's narrow fix with a phased design whose exit criterion is a machine-checked proof:

Every flag, every input format, every architecture, and every input-carried semantic that a supported driver can hand reld is either honored natively, honored by a bundled engine reld routes to, or rejected loudly. Nothing is silently dropped, and nothing is routed to an engine that also drops it.

Until Phase 6's gate is green, soldr keeps SOLDR_LINKER=fast on its current defaults. zackees/soldr#3262 is blocked on this issue.

What the audit found

Everything below was verified against the code on escalate-ignored-flags (8420e6c1), against ld.lld/lld-link/ld64.lld 21.1.8 (rust-lld from the 1.95 toolchain), and against rustc --print link-args / clang -### / gcc -### output on this host.

A. The 8420e6c1 escalation routes to an engine that ignores the same flags

bridge.rs now declares ArchiveGroups, NoStdlib, SortCommon, Stats as capabilities of the lld engine. They are not.

Flag native reld (before) ld.lld Net effect
--start-group / --end-group / -( / -) no-op (cycles resolved by construction) "Ignored for compatibility with GNU" (its own --help) Nothing gained. Link leaves the native engine for no reason.
--sort-common no-op accepted silently, no effect Nothing gained.
--stats no-op accepted silently, no stats printed Nothing gained.
--nostdlib no-op (native has no built-in search paths) honored, but lld also has no built-in search paths Nothing gained.

The capability table was extended by asserting, not by measuring. These four flags are not correctness-affecting on either engine, so the correct classification is satisfied-by-construction, not route. The mis-classification has a real cost: clang and gcc emit --start-group … --end-group on every -static and -static-pie link, so every +crt-static glibc build now silently leaves the native engine.

Side regression: the flags were removed from the native parser entirely, so the documented escape hatch RELD_UNSUPPORTED=ignore --engine=reld with --start-group on the line now fails with "unrecognized option" where it used to link.

The model is missing a class. Today a flag is honored, routed, or unknown. It needs satisfied-by-construction (the native engine's unconditional behavior already implies the flag's semantics), each with a test proving the equivalence.

B. Flags still dropped, mis-routed, or wrongly fatal after 8420e6c1

Flag / input What happens today Verified how
-C link-dead-code (rustc omits --gc-sections) Native defaults gc_sections: true "because it's faster" (args/elf.rs). rustc expresses link-dead-code by not passing --gc-sections, so reld GCs anyway. The user's request is silently violated. GNU ld, lld, and mold all default to no GC. rustc --print link-args -C link-dead-code has no --gc-sections; code read.
--fix-cortex-a53-835769 In IGNORED_FLAGS: native warns and links. Not in the classifier. ld.lld rejects it. No bundled engine has it, so this must be a loud error. ld.lld --fix-cortex-a53-835769 …unknown argument.
-X (--discard-locals) Classifier lowercases every token, so -X hits the -x arm and is routed to lld as DiscardAll. -X is a native default. lower == "-x" in collect_requested_capabilities.
-plugin-opt=… + bitcode inputs rustc -C linker-plugin-lto through clang emits -Wl,-plugin-opt=O0,-plugin-opt=mcpu=x86-64 and no -plugin/-flto. Not a trigger. The link stays native and is handed LLVM bitcode. rustc --print link-args -C linker-plugin-lto -C linker=clang.
--no-undefined-version rustc emits it on every cdylib, dylib, and proc-macro link, so all of them route to lld. Not a mislink, but the native engine never links a proc-macro, invisibly. mold implements the check natively; it is a version-script lookup. rustc --print link-args --crate-type proc-macro.
-z lazy No-op. Native unconditionally emits DF_BIND_NOW + DF_1_NOW. -z now is satisfied by construction; -z lazy is silently converted. Needs a deviation-register entry. elf_writer.rs dt_flags().
-z nostart-stop-gc, -z notext No-op handlers. start_stop_eligible retention exists in layout, so nostart-stop-gc is probably satisfied by construction and -z start-stop-gc is the one that would be dropped. Neither has a test. Code read.
Unknown -z <kw> warn_unsupported → warning + continue. Drivers emit: -z text (clang -static-pie), -z separate-code, -z start-stop-gc, -z force-bti, -z pac-plt, -z bti-report=, -z cet-report=, -z shstk, -z ibt, -z dynamic-undefined-weak, -z nognustack, -z global. lld honors all of these. clang -### -static-pie; -z fallback arm.
--pack-dyn-relocs=android*, --icf=<other>, --sort-section=<other> warn + drop. Code read.
-z text Unknown -z keyword → warn + drop. Native never errors on text relocations; it just sets DT_TEXTREL when they occur. lld's default is -z text (error). So -z notext is satisfied by construction and the hardening flag -z text is silently lost. elf_writer.rs DT_TEXTREL; -z fallback arm.
-l:libfoo.a (exact-name library) rustc emits -l:libfoo.a for -l static:+verbatim=… / #[link(modifiers = "+verbatim")]. The native resolver formats lib{name}.so / lib{name}.a, so the lookup becomes lib:libfoo.a.so and fails "not found". Loud, but wrong; GNU ld and lld honor -l:. rustc --print link-args -l static:+verbatim=libfoo.a; input_data.rs.
--hash-style=none, --compress-debug-sections=zlib:<level> / zstd:<level> Declared flags whose value variants bail. lld honors both. hash-style.sh, compress-debug-sections-*-level.sh in the mold skip list.
-z nodefaultlib, -z nodump, -z rodynamic, -z sectionheader, -z start-stop-visibility=, -z initfirst, -z cet-report=, -z dynamic-undefined-weak Not declared or not conformant; the mold suite pins each as failing. z_options and misc groups in mold_skip_tests.toml.

C. ~150 ld.lld options are a hard error natively instead of a route

A mechanical diff of every .long(…) the native ELF parser declares (114) against ld.lld --help (267 long options) leaves roughly 150 options that native reld reports as unrecognized option(s) even though a bundled engine honors them. That is loud, so it is not a silent drop, but it is the opposite of "runs everywhere by routing". Ones real users pass with -C link-arg= or -Wl,:

  • Output shaping: --emit-relocs, --image-base, --oformat/-b binary/--format, --omagic, --just-symbols, --filter, --init/--fini, --undefined-glob, --keep-unique, --unique, --package-metadata, --dependent-libraries/--no-dependent-libraries
  • Ordering/PGO: --symbol-ordering-file, --call-graph-ordering-file, --call-graph-profile-sort, --bp-*, --shuffle-sections, --randomize-section-padding
  • Diagnostics: -Map/--print-map, --cref, --print-gc-sections, --print-icf-sections, --why-extract, --why-live, --trace-symbol, --verbose, --reproduce, --time-trace, --error-limit, --no-warnings/-w, --warn-backrefs, --warn-common, --check-sections
  • LTO: every --lto-*, --thinlto-*, --fat-lto-objects, --save-temps, --mllvm, --load-pass-plugin, --opt-remarks-*
  • Arch-specific: --android-memtag-*, --execute-only, --fix-cortex-a8, --be8, --cmse-implib/--in-implib/--out-implib, --power10-stubs, --toc-optimize, --pcrel-optimize, --relax-gp, --target1-*, --target2
  • GNU aliases: -dn/-dy/-call_shared/-non_shared, --library, --library-path, --default-script

GNU-ld-only flags (no bundled engine: --no-warn-rwx-segments, --no-warn-execstack, --gc-keep-exported, --hash-size, --disable-linker-version, -plugin-save-temps) need an explicit disposition too. Most are satisfied by construction (reld never emits those warnings) and should be accepted, not rejected.

D. Architecture and format are host-keyed, not target-keyed

PlatformKind::host() decides ELF/COFF/Mach-O from the host OS unless argv[0] is ld/ld64/reld-link or -flavor is given. Architecture::try_from(e_machine) accepts exactly five machines. Nothing inspects the requested target before choosing an engine.

Direction What reld receives What happens A bundled engine could
clang -m32 / any i386 objects -m elf_i386 or EM_386 inputs -m elf_i386 is not yet supported / Unsupported architecture: 0x3 ld.lld links i386
arm32, mips, s390x, sparc, ppc64 big-endian, riscv32, hexagon, avr, msp430 -m armelf_linux_eabi, elf32lriscv, -EB, … hard error ld.lld links all of them
Linux host → x86_64-pc-windows-gnu (mingw driver) -m i386pep -lmoldname … ELF parser: -m i386pep is not yet supported. #90 tracks windows-gnu with no engine behind it. ld.lld -m i386pep (lld's MinGW driver)
Windows host → windows-gnu (gcc driver invoking reld.exe) GNU-style argv Host default is COFF → forwarded verbatim to lld-link, which does not speak GNU syntax same
Linux host → aarch64-apple-darwin (clang --target=arm64-apple-darwin --ld-path=reld) -arch arm64 -platform_version … ELF parser: unrecognized options ld64.lld ships in every Rust toolchain
Linux host → x86_64-pc-windows-msvc /OUT: /DEBUG … ELF parser treats /OUT:… as an input file. The classifier already knows /LTCG. lld-link
macOS host, rustc default flavor (darwin-cc), CARGO_TARGET_*_LINKER=reld -Wl,-dead_strip -nodefaultlibs -arch … -mmacosx-version-min=… Forwarded to ld64.lld, which rejects driver flags. CI passes only because it also sets -Clinker-flavor=ld64.lld. soldr#3262's "direct reld on macOS" is wrong as written. reld could unwrap the cc flavor, or soldr injects the flavor flag

E. Input-carried semantics that no flag audit sees

The native engine's behavior on these is decided by section flags and notes in the inputs, not by argv. They must be classified the same way flags are.

Input semantic Native today lld
SHF_GNU_RETAIN (clang __attribute__((retain)), rustc #[used(linker)]) Not referenced anywhere in reld-core. GC can drop sections the compiler marked retained. honored
SHT_LLVM_DEPENDENT_LIBRARIES (.deplibs, clang #pragma comment(lib) on ELF) not referenced honored by default
AArch64 BTI/PAC .note.gnu.property merging, -z force-bti, -z pac-plt not referenced; -z forms warn-and-drop honored
x86 CET (-z cet-report, shstk, ibt) x86 ISA-level notes handled; CET flags warn-and-drop honored
Linker scripts Top-level OUTPUT_ARCH is silently ignored; any other unknown top-level command (SEARCH_DIR, INSERT, REGION_ALIAS, EXTERN, STARTUP, INCLUDE, NOCROSSREFS) becomes Command::Arg and is later treated as an input path; output-section data commands (LONG, BYTE, FILL, SUBALIGN, NOLOAD, ONLY_IF_R*, >region, :phdr) are unparsed. extern "C++" in version scripts bails. full grammar
Relocatable output -r supported, with a documented TODO that debug sections are not cleaned up like ld/mold honored
Default --gc-sections without the flag GCs (see §B -C link-dead-code) no GC

F. The bridged formats have their own silent-ignore class that reld cannot see

lld-link accepts, without any diagnostic, link.exe flags it does not implement: /PROFILE, /LTCG, /LTCG:INCREMENTAL, /INCREMENTAL, /ALLOWBIND, /FASTFAIL, /GENPROFILE, /USEPROFILE, /EMITPOGOPHASEINFO, /LTCGOUT, /ASSEMBLYMODULE among others (all probed). An unknown /FLAG is treated as an input file (could not open '/BOGUSFLAG'). ld64.lld likewise silently accepts -no_deduplicate, -object_path_lto, -no_adhoc_codesign, -search_paths_first, -objc_abi_version, -export_dynamic, -encryptable, -reproducible, … and rejects -warn_duplicate_libraries and -ld_classic. Because the bridge forwards argv verbatim and "never falls back to link.exe/ld64" (decision B2), these are silent drops with no more-capable engine. They need a deviation register sourced from lld's own Options.td "ignored" groups, not from probing, since silence does not distinguish honored from ignored.

G. Observability gaps that block soldr

  • reld --version on macOS/Windows is bridged to lld (ci.yml says so). soldr's linker_candidate_identity keys its PEP 517 fallback cache on --version, so a reld upgrade never invalidates it there.
  • Routing is visible only with RELD_LOG_ENGINE=1. Nothing lets a build system require a route, so a regression like §A is invisible in CI.
  • Bridge discovery spawns rustc --print sysroot and rustc -vV on every routed link, and picks the rust-lld of whatever toolchain the current directory's rust-toolchain.toml selects. So the lld version that runs varies by cwd, and the invocation log records the engine name but not the resolved linker path or version.
  • Native reld honors cargo's jobserver (jobserver::Client in args.rs); a bridged lld does not, so every routed link under cargo -j N oversubscribes the machine. The bridge should translate acquired tokens into --threads=N.
  • Three response-file grammars coexist: args.rs::read_args_from_file (quotes + escapes, used by the native parser and the classifier), bridge.rs::response_arguments (quotes only, used for the audit log's output path), and lld's --rsp-quoting. The mold suite already pins response-file-quoting.sh as failing.
  • --version prints Reld <v> (compatible with GNU linkers). CMake ≥ 3.29 linker-type detection and similar tooling match GNU ld / LLD / mold; reld is detected as none of them, so linker-specific flag sets may be skipped. Decide which family string to claim.
  • Two native code paths are unreachable under default routing and absent from the engine table: the GNU-plugin LTO path (linker_plugins.rs, acceptance tests gated on RequiresLinkerPlugin) because -plugin is classified Lto → lld first, and the native Mach-O backend (macho.rs, macho_writer.rs, Args::MachO → macho::link_for_arch) because default_for(MachO) is always the bridge. The table lies in both directions.

I. Declared is not conformant: the mold skip list is an inventory the flag audit cannot see

A flag can be declared, parsed, and stored, and still not do what GNU ld/lld/mold do. The Phase 1 external-suite ratchet (crates/reld/tests/external_tests/mold_skip_tests.toml, #14) already pins that debt, 17 groups deep, and none of it is connected to routing today. A presence-based table would mark every one of these Native and pass.

  • unsupported_options (47 tests): --compress-debug-sections=zlib:N, --defsym=foo=_GLOBAL_OFFSET_TABLE_, --dynamic-list data symbols, --emit-relocs, --execute-only, --filter, --image-base, --init/--fini, --library, --oformat=binary, --omagic, --no-warnings, --package-metadata, --require-defined, --retain-symbols-file, --section-alignment, --trace-symbol, --undefined-glob, --warn-common, --warn-once, --audit/--depaudit, --default-symver, --spare-program-headers, -Map, --repro, response-file quoting, --fatal-warnings+-warn-common, thin/static archive cases that pass with GNU ld.
  • z_options: cet-report, dynamic-undefined-weak, nodefaultlib, nodump, rodynamic, sectionheader, start-stop-visibility.
  • symbol_versioning, tls, icf_semantics, as_needed_gc, static_dso, symtab_binding: semantic gaps not attached to any flag: --as-needed interacting with GC, TLS LE/common cases, symbol-table binding, version-script edge cases.
  • misc (~40 tests, "awaiting classification") includes declared flags that fail: -z nocopyreloc, -z initfirst, --strip-*, --dynamic-linker, --noinhibit-exec, --no-undefined-version, -Bno-symbolic, --defsym with a missing symbol, --discard-*, common-symbol handling, weak-undef cases, symtab*, textrel2.
  • ignore group deviations that are real user-visible differences, not message-format noise: gc-sections.sh, start-lib.sh, undefined.sh, undefined2.sh, whole-archive.sh all "pass when --no-gc-sections is passed" (the §B GC-default deviation, pinned five times); hash-style.sh (none unsupported); as-needed-weak.sh; copy relocations placed in .bss instead of .copyrel; plt-symbols.sh; build-id data size.
  • lto: the native plugin path "doesn't support LTO without an explicit plugin", -m llvm is unsupported, COMDAT in LTO is unsupported. That is the same unreachable path as §G.

Every skip entry that names a flag or -z keyword must resolve to a table disposition: Requires(lld) until fixed natively, or Native with the mold test un-skipped. Semantic groups without a flag gate the soldr default through the #14 ratchet count instead.

J. Route-dependent divergence: the same argv means different things on different engines

Routing is only honest if a link produces the same semantics whichever engine runs it. Today it does not.

  • The Nix RUNPATH derivation (feat(elf): derive RUNPATH for Nix-store -L directories #111) is native-only. add_nix_rpath_entries runs inside the native ELF parser, reading NIX_STORE / NIX_DONT_SET_RPATH. A link that routes to lld for any reason gets no derived RUNPATH. On NixOS with a rustup toolchain that is every proc-macro and cdylib link today (§B --no-undefined-version), every +crt-static link (§A), and every LTO link: the exact rust-lld default on x86_64-unknown-linux-gnu produces binaries without RUNPATH on NixOS rust-lang/rust#162781 failure reld exists to fix comes back on the routed path. crates/reld/tests/nix_rpath.rs only ever links natively, so nothing catches it.
  • 29 reld-only or GNU-only options are forwarded verbatim to lld. comm of reld's declared long options against ld.lld's inventory: --debug-fuel, --discard-sframe, --fallocate-output-file/--no-…, --fork/--no-fork, --gc-stats-ignore, --got-plt-syms, --madvise-huge-pages/--no-…, --nix-rpath, --no-identity-comment, --no-string-merge, --no-threads, --no-update-in-place/--update-in-place, --prepopulate-maps, --reld-experimental-sframe, --reld-experiments, --rpath-link, --sym-info, --thread-count, --time, --verbose-gc-stats, --write-gc-stats. Only --validate-output, --write-layout, --write-trace are classified NativeControl, and only --engine= is stripped. Any of the other 26 on a line that also routes (say --time --icf=all) is handed to lld, which rejects it as an unknown argument. The reld-only performance knobs (--fork, --madvise-huge-pages, --prepopulate-maps, --fallocate-output-file) are exactly what a tuned soldr profile would pass.
  • Capability sets are copy-pasted across formats. COFF_LLD_ENGINE and MACHO_LLD_ENGINE declare the ELF-shaped LLD_CAPABILITIES (Icf, CortexA53Erratum, VersionScriptPolicy, …). The /LTCG arm in the classifier lives on the ELF-only path and can never fire for a COFF link. Harmless today only because non-ELF requirement lists are always empty.
  • A bridged child killed by a signal exits 1. status.code().unwrap_or(1) discards the signal, so cargo reports "exit code: 1" with nothing attached. This is the same bare-exit-1 shape soldr#1992 spent a session on.

K. Native defaults that differ from GNU ld with no flag on the line

These never show up in a flag audit because nothing is passed. Each needs a deviation-register entry and a decision.

Default GNU ld ld.lld native reld Effect
GC off off on (gc_sections: true) §B -C link-dead-code; five mold tests pass only with --no-gc-sections
-rpath tag DT_RPATH (enable_new_dtags off) DT_RUNPATH DT_RUNPATH (enable_new_dtags: true) LD_LIBRARY_PATH precedence and transitive-dependency search differ from a GNU-ld build with the same -Wl,-rpath
Executable stack inferred from .note.GNU-stack (missing note → execstack + warning) off off, no inference (execstack: false) arch-x86_64-execstack-if-needed.sh is skipped
--hash-style sysv (distro-configured both) sysv/both both fine
RELRO on on on fine
-z text / notext notext text notext, cannot be turned on (§B) hardening flag lost
Text relocations warning warns error silent warn-textrel tests skipped
Copy relocations of read-only data .bss.rel.ro (RELRO-protected) .bss.rel.ro .bss writable after RELRO; copyrel-relro*.sh skipped
SHF_GNU_RETAIN under --gc-sections kept kept dropped §L: coverage and #[used] registries
LD_RUN_PATH default rpath ignored ignored matches lld; differs from GNU ld

L. Retained sections, coverage, and environment: GC drops what the compiler said to keep

  • rustc marks #[used] statics and coverage sections SHF_GNU_RETAIN. Verified with readelf on rustc 1.95 objects: #[used] #[link_section = "myreg"]myreg AR; -C instrument-coverage__llvm_covmap R, __llvm_covfun GR, __llvm_prf_names AR. reld-core has zero references to SHF_GNU_RETAIN, to __llvm_cov*, or to __llvm_prf_*, and rustc always passes --gc-sections. So the native engine GCs the coverage mapping (cargo llvm-cov gets empty or partial reports) and any #[used] static in a custom section that nothing references (registry patterns: inventory, linkme on sections without __start_ references, plugin tables). lld keeps all of them. No reld test exercises -C instrument-coverage.
  • LD_RUN_PATH is honored by GNU ld as the default -rpath and by neither lld, mold, nor reld (the only env vars native reld reads are NIX_DONT_SET_RPATH, NIX_STORE, PATH, RELD_PRINT_ALLOCATIONS, RELD_TEST_IGNORE_FORMAT). A deviation-register entry, not a bug, but autotools-era builds rely on it.
  • Non-UTF-8 argv panics. Args::new(std::env::args) uses the panicking accessor; the classifier's arg.to_str() silently skips the same tokens. A Latin-1 path on the line is a crash in one place and invisible in the other. lld handles bytes.
  • Bridge discovery goes through the rustup proxy. rustc --print sysroot under a rust-toolchain.toml that names an uninstalled toolchain makes rustup download a toolchain from inside a linker invocation (rustup's default auto-install). Discovery must honor RUSTUP_TOOLCHAIN/an explicit RELD_BRIDGE_LINKER, or resolve rust-lld relative to the rustc that spawned reld (cargo exports RUSTC/CARGO), never by re-resolving the cwd.
  • Copy relocations land in .bss instead of .bss.rel.ro (five copyrel-*.sh mold tests skipped as "we put copy relocation info to .bss"). Copy-relocated const data from a shared library is therefore writable after RELRO, a hardening regression GNU ld, lld, and mold do not have.

H. What is fine (so nobody re-audits it)

  • clang --ld-path=<abs reld> beats rustc's own injected -B gcc-ld -fuse-ld=lld, in either order, no warning. soldr's Linux injection is sound.
  • Every token rustc emits on ordinary Linux links across opt-level, strip=*, relocation-model=static, panic=abort, prefer-dynamic, lto=fat, target-cpu, split-debuginfo, link-self-contained=no is declared natively: --as-needed, -Bstatic/-Bdynamic, --eh-frame-hdr, --gc-sections, -O1, --strip-all, --strip-debug, -z noexecstack, -z relro, -z now, -pie. Only --no-undefined-version (§B) and +crt-static (§A) leave the native engine.
  • clang -fsanitize=address tokens (--whole-archive, --dynamic-list=, --no-as-needed) and -shared -rdynamic -Bsymbolic-functions --build-id are declared natively.
  • COFF/Mach-O response files are left opaque to the router; correct, since MSVC emits UTF-16.
  • --engine= is stripped before forwarding and validated against format and capabilities.
  • Every field the ELF parser stores is consumed somewhere (checked mechanically; the accessor names differ from the field names, e.g. z_stack_sizestack_size_override). No declared flag is parsed into a dead field.

Design

D1. Four-way classification over the union of engine inventories

Replace SILENTLY_IGNORED_FLAGS, IGNORED_FLAGS, DEFAULT_FLAGS, DEFAULT_SHORT_FLAGS, the -z fallback, every warn_unsupported value arm, and the if lower == … chain in collect_requested_capabilities with one declarative table in bridge.rs:

enum Disposition {
    /// Native parser implements it. Names the test that proves it.
    Native,
    /// Native engine's unconditional behavior already implies it. Names the
    /// equivalence test (native vs lld, on the flag's probe workload, comparing
    /// the property the flag governs).
    SatisfiedByConstruction,
    /// Native cannot honor it; route to the first engine whose *measured*
    /// capability set includes it.
    Requires(Capability),
    /// No bundled engine honors it. Loud error unless RELD_UNSUPPORTED=ignore.
    Unsupported,
}

struct FlagRule {
    /// Exact spellings, case-sensitive for GNU/ld64, case-insensitive for COFF.
    spellings: &'static [&'static str],
    value: ValueMatch,          // `--flag=value`, `-z value`, `/FLAG:value`
    disposition: Disposition,
    emitters: &'static [Emitter], // rustc, clang, gcc, cc-rs, cmake, hand-written
}

Rules:

  • The table's key set is the union of lld's Options.td inventories for ELF, MinGW, COFF, and Mach-O at the pinned LLVM version (vendored, regenerated by a script), plus GNU ld's option list, plus reld-only flags. A CI check fails if any inventory entry lacks a rule. This is what turns §C's ~150 "unrecognized option" errors into routes, and what gives §F a deviation register.
  • Every Capability an engine claims carries provenance: Measured { probe } (Phase 2 runs the probe in CI) or Documented { source } pointing at the engine's option table. Options lld marks "ignored for compatibility" are SatisfiedByConstruction on that engine, never a capability.
  • Native handlers may not be |_, _| Ok(()). A no-op is only legal as a SatisfiedByConstruction rule with its test.
  • Native is a conformance claim, not a parse claim. Each Native rule names its conformance evidence: an acceptance test, or the mold-suite tests that exercise it. A rule whose named mold tests are in the skip list cannot be Native; it is Requires(lld) until the skip is removed (§I).
  • Library names, not just flags: -l:exact, -l with --push-state/-Bstatic, --start-lib/--end-lib, and +verbatim all get rules with tests.
  • reld --print-flag-table emits the table; docs/flags.md is generated from it and checked in CI. README and polylinker.md "shipped vs designed" claims are derived from it.

D2. Route by target, architecture, and inputs, not host

select_route gains a TargetProbe that runs before format dispatch, in this order:

  1. -flavor / argv[0] (existing).
  2. -m <emulation> / OUTPUT_FORMAT / -EB/-EL: the five native ELF targets → native; every other ELF emulation lld knows → ld.lld; i386pep, i386pe, arm64pe, thumb2pe → new Engine::MingwLld (ld.lld -m …, lld's MinGW driver). This is the engine chore: raise MSRV to Rust 1.95 and define Windows GNU acceptance #90 is missing.
  3. -arch, -platform_version, -syslibroot, -dead_strip → Mach-O via ld64.lld.
  4. /OUT:, /DEFAULTLIB:, /SUBSYSTEM: (COFF grammar, case-insensitive) → lld-link.
  5. Inputs. Before parsing, peek the first object/archive member: EI_CLASS, EI_DATA, e_machine outside the native set → ld.lld; LLVM bitcode magic (BC\xC0\xDE or the wrapper header) → Requires(Lto). Routing on inputs is what makes -C linker-plugin-lto safe regardless of which flags the driver emitted.
  6. Linker scripts: if -T/--script/an implicit script uses any command outside the native grammar (INSERT, REGION_ALIAS, LONG, FILL, >region, :phdr, extern "C++" in version scripts, …) → Requires(FullLinkerScript)ld.lld. Never Command::Arg an unknown command.
  7. Otherwise host default.

D3. Deviation register

Every SatisfiedByConstruction rule, every -z no-op, the --gc-sections default, -z lazy → now, the -r debug-section TODO, and every lld/lld-link/ld64.lld "ignored for compatibility" option gets an entry with: what the user asked, what reld does, and the acceptance test that pins the property (archive-cycle result, DT_FLAGS bits, section retention for SHF_GNU_RETAIN and __start_/__stop_, .deplibs expansion, TEXTREL presence, search-path set). The register is user-visible in docs/flags.md.

--gc-sections default changes to match every other linker: GC only when asked, --no-gc-sections respected, and -C link-dead-code gets a corpus row proving dead code survives.

D4. Emitter corpus: prove coverage against what drivers actually send

ci/flag_corpus.py captures linker argv from real emitters on every CI host and asserts that every token (and every input's magic, and every linker-script command) matches exactly one FlagRule. An unmatched token fails CI. This is the "no gaps" proof soldr is waiting for.

Emitter Configurations
rustc --print link-args bin, cdylib, dylib, proc-macro, staticlib; opt-level 0/3; -C lto=off/thin/fat; -C linker-plugin-lto; +crt-static; -C relocation-model=static; panic=abort; -C strip=debuginfo/symbols; -C link-dead-code; -C prefer-dynamic; -C link-self-contained=+linker on/off; -C linker-flavor=ld64.lld and default on Apple
Targets x86_64-unknown-linux-gnu, -musl, aarch64-unknown-linux-gnu, i686-unknown-linux-gnu, x86_64-pc-windows-msvc, x86_64-pc-windows-gnu, aarch64-apple-darwin, x86_64-apple-darwin, thumbv7em-none-eabi (cortex-m-rt link.x/memory.x), x86_64-unknown-none (custom -T script)
clang -### / gcc -### default, -static, -static-pie, -shared, -m32, -flto, -fsanitize=address, -rdynamic, -Wl,-Bsymbolic-functions, --ld-path with rustc's -fuse-ld=lld still present, Debian/Fedora/NixOS wrapper hardening sets (-z pack-relative-relocs, --package-metadata, -z now, --as-needed, --no-warn-rwx-segments)
Output targets regular file, existing running executable (ETXTBSY), -o /dev/null, tmpfs, overlayfs (Docker), 9p/virtiofs (VM shares), a path with spaces via @response
cc-rs / cmake existing ci/consumer_acceptance.py C/C++ projects, plus one CMake project using --version-script, --whole-archive, -Bsymbolic-functions, -Map, and __attribute__((retain))
Windows / macOS rustc's full MSVC set (/DEBUG, /PDBALTPATH, /OPT:REF,NOICF, /NXCOMPAT, /defaultlib:), cc-rs on MSVC, rustc's ld64 set, cc-rs on macOS; every token classified against the vendored COFF/Mach-O inventories

The corpus is versioned (ci/flag-corpus.lock.json, same pattern as clang-link-corpus.lock.json) so a toolchain bump that introduces a new flag fails loudly rather than drifting.

D5. Observability and identity

  • RELD_REQUIRE_ENGINE=<name>: fail the link if routing selects anything else. CI pins "rustc default bin link is native", "proc-macro link is native", "+crt-static is native", "-C linker-plugin-lto is lld".
  • reld --reld-identity: prints reld's version, git SHA, and flag-table hash and never bridges. soldr's identity probe switches to it.
  • RELD_INVOCATION_LOG records gain a per-token disposition list so an audit can show why a link left native.
  • Bridge discovery is cached per process tree (env-keyed), resolves relative to RUSTC/RUSTUP_TOOLCHAIN when present, never triggers a rustup install, and records the resolved linker path + version in RELD_INVOCATION_LOG; discovery no longer depends on cwd.
  • Bridged links receive --threads=<jobserver tokens> so routed links respect cargo -j.
  • One response-file grammar, conformant with response-file-quoting.sh, used by the classifier, the native parser, and the audit log.
  • --version claims one linker family string deliberately (test against CMake's detection regexes).
  • RELD_STRICT_BRIDGE=1: for bridged formats, promote any token whose deviation-register entry is "ignored by lld-link/ld64.lld" to an error. Default stays permissive for compatibility; soldr's CI turns it on.

D6. Route invariance

Anything reld adds on top of "be a linker" is an argv-level transformation, applied before engine selection, so every engine sees the same request:

  • The Nix RUNPATH derivation becomes derive_nix_rpath(argv) -> Vec<"-rpath", dir> appended to the forwarded argv for every route, with nix_rpath.rs parameterized over RELD_ENGINE=reld|lld.
  • Every reld-only option is either NativeControl (forces native; conflicts loudly with a routed requirement) or Stripped (a performance/diagnostic knob that is dropped, with a RELD_LOG_ENGINE note, when the link leaves native). The table decides; nothing reld-only reaches a child linker.
  • Capability sets are declared per format; the classifier has one arm set per grammar (GNU, COFF, ld64) and a COFF/ld64 arm can never live on the ELF path.
  • A bridged child's signal is propagated (exit(128+sig) and a stderr line naming the signal and the engine).

Phases

Each phase is independently mergeable with its own tests. Phases 0–3 are on soldr's critical path; 4–5 are required for "runs everywhere"; 6 is the gate.

Phase 0 — Stop the bleeding

  • Reclassify ArchiveGroups, NoStdlib, SortCommon, Stats as SatisfiedByConstruction; remove them from LLD_CAPABILITIES; restore the flags to the native parser. Keep the Escalate silently-ignored flags to lld — never drop linker semantics (reld-as-default prerequisite) #121 tests but invert them: these flags stay native.
  • Case-sensitive classifier for GNU flags. Test: -X native, -x routes.
  • -plugin-opt becomes an Lto trigger (interim until D2 step 5).
  • --fix-cortex-a53-835769Unsupported (loud).
  • --gc-sections default → off unless requested. Corpus row for -C link-dead-code. Un-skip gc-sections.sh, start-lib.sh, undefined.sh, undefined2.sh, whole-archive.sh.
  • -z textRequires(lld) until implemented natively (Phase 2); -z notextSatisfiedByConstruction with a TEXTREL test.
  • -l:name exact-name resolution implemented natively (it is a path formatting fix). Corpus row for -l static:+verbatim=.
  • All 26 unclassified reld-only options become NativeControl or Stripped (D6). Test: --time --icf=all links through lld with --time dropped and logged; --sym-info=x --icf=all errors loudly.
  • Nix RUNPATH derivation applied on the lld route; nix_rpath.rs runs under both RELD_ENGINE values.
  • Bridged child signals propagated.
  • Exit: +crt-static and -static C links route native; -C linker-plugin-lto routes to lld; -C link-dead-code keeps dead code; all asserted with RELD_LOG_ENGINE.

Phase 1 — The flag table (D1)

  • Vendor lld's ELF/MinGW/COFF/Mach-O Options.td inventories and GNU ld's option list at pinned versions; generator script + CI drift check.
  • Introduce FlagRule/Disposition; migrate every existing arm, table, -z sub-option, and value fallback. Every inventory entry gets a rule or CI fails. §C's ~150 options become Requires(LldOption) routes; GNU-only warning suppressors become SatisfiedByConstruction.
  • Capability provenance (Measured/Documented).
  • reld --print-flag-table; docs/flags.md generated and checked.
  • Exit: SILENTLY_IGNORED_FLAGS, IGNORED_FLAGS, DEFAULT_FLAGS, DEFAULT_SHORT_FLAGS no longer exist; no Ok(()) no-op handler exists outside the table; RELD_UNSUPPORTED=ignore --engine=reld works again for satisfied-by-construction flags.

Phase 2 — Native implementations of the cheap, common escalations

Everything rustc emits on ordinary links must stay native or the default is a perf regression in disguise.

  • --no-undefined-version / --undefined-version: native.
  • --fatal-warnings / --no-fatal-warnings, --color-diagnostics[=…], --no-warnings/-w, --error-limit: native (they are diagnostics plumbing).
  • SHF_GNU_RETAIN honored in GC; .deplibs expanded; -z start-stop-gc/nostart-stop-gc both real with tests. Acceptance test: -C instrument-coverage binary produces a .profraw that llvm-cov can map, and a #[used] #[link_section] static with no references survives.
  • Copy relocations of read-only data go to .bss.rel.ro; un-skip copyrel-relro*.sh.
  • args_os everywhere; non-UTF-8 paths link.
  • -z keywords lld honors and drivers emit (text, separate-code/noseparate-code, nognustack, global, dynamic-undefined-weak, force-bti, pac-plt, bti-report, cet-report, shstk, ibt): each Native, SatisfiedByConstruction with a D3 test, or Requires(ZKeyword(kw)). None remains warn-and-continue.
  • -z text native (error on text relocations, matching lld's default when the flag is given); text-relocation warning by default.
  • §K decisions: .note.GNU-stack inference for the executable-stack default (match GNU ld and lld's warning), and an explicit, documented choice for DT_RPATH vs DT_RUNPATH when neither --enable-new-dtags nor --disable-new-dtags is given.
  • Burn down §I for every flag rustc, clang, gcc, and cc-rs emit: unsupported_options, z_options, and misc entries that name such a flag become Native with the mold test un-skipped, or Requires(lld). The remaining semantic groups (tls, as_needed_gc, symbol_versioning, symtab_binding, static_dso) get a ratchet count that must be non-increasing and is reported in the Phase 6 gate.
  • Decide the two unreachable native paths (§G): GNU-plugin LTO and native Mach-O either enter the engine table as Capability/Engine entries with probes, or are deleted. No unreachable engine.
  • Exit: every D4 rustc row except -C lto=*/linker-plugin-lto runs under RELD_REQUIRE_ENGINE=reld.

Phase 3 — Target-keyed routing (D2), Linux and macOS hosts

  • TargetProbe from -m, -EB/-EL, OUTPUT_FORMAT, -arch/-platform_version, /OUT:, input e_machine/EI_CLASS/EI_DATA, bitcode magic, and linker-script grammar.
  • Engine::MingwLld. clang --target=x86_64-w64-mingw32 --ld-path=reld links on Linux.
  • i386, arm32, mips, s390x, ppc64be, riscv32 objects route to ld.lld instead of Unsupported architecture.
  • Mach-O cross from Linux via ld64.lld (rustc -Clinker-flavor=ld64.lld and clang --target=arm64-apple-darwin).
  • macOS host rule, decided here: either reld accepts rustc's darwin-cc flavor by unwrapping -Wl, and translating driver-only flags (-arch, -nodefaultlibs, -mmacosx-version-min-platform_version), or soldr injects -Clinker-flavor=ld64.lld. The soldr issue is updated with the choice.
  • Exit: cross-ship links windows-gnu with reld; chore: raise MSRV to Rust 1.95 and define Windows GNU acceptance #90's acceptance has an engine; thumbv7em/x86_64-unknown-none corpus rows route to lld with their scripts intact.

Phase 4 — Windows host parity and bridged-format deviation register

  • Windows host + windows-gnu argv → MingwLld, not lld-link.
  • Vendor lld-link's and ld64.lld's "ignored for compatibility" groups into the deviation register (§F); RELD_STRICT_BRIDGE promotes them to errors.
  • Unknown /FLAG on COFF is diagnosed as an unknown flag, not could not open '/FLAG'.
  • Audit rustc's and cc-rs's full MSVC and ld64 token sets against the vendored inventories.
  • Exit: D4 rows for both Windows targets and both Apple targets pass on their runners with RELD_STRICT_BRIDGE=1.

Phase 5 — Observability and identity (D5)

  • RELD_REQUIRE_ENGINE, --reld-identity, per-token disposition in RELD_INVOCATION_LOG, RELD_STRICT_BRIDGE.
  • ci/consumer_acceptance.py runs the Linux Rust project under RELD_REQUIRE_ENGINE=reld and its LTO variant under RELD_REQUIRE_ENGINE=lld.
  • Exit: soldr's identity probe can switch to --reld-identity on every host.

Phase 6 — The no-gaps gate (D4) and soldr hand-off

  • ci/flag_corpus.py in CI on all three hosts with the full matrix; any unmatched token, input magic, or script command is red.
  • README and polylinker.md regenerated from the flag table.
  • Only when this job is green on main: reopen Make reld the default linker for soldr soldr#3262 with the corrected platform rules (Linux: clang --ld-path=<abs> plus a clang-on-PATH probe so a missing driver falls back instead of breaking every build; macOS: whichever Phase 3 chose; Windows: direct reld; identity via --reld-identity), plus three soldr-side fixes the audit surfaced:
    • Cross targets. TargetKind::Linux injects clang --ld-path for any -linux- triple with no --target=<triple> or sysroot, so soldr cargo build --target aarch64-unknown-linux-gnu from x86_64 hands host-targeted clang aarch64 objects. Inject -C link-arg=--target=<triple> or restrict the reld default to host-native triples. The musl row must actually link, not just print args.
    • Silent fallback cache. The PEP 517 path retries a failed reld link with the platform linker and caches that fallback keyed by the (currently broken on macOS/Windows, §G) identity. A reld bug is then hidden for the cache lifetime. The fallback must be attached to the failing compile's diagnostics and reported, never cached silently (soldr#1992's diagnostic gap, third instance).
    • soldr#1992 root cause. rust-lld as a direct -C linker fails proc-macro DLL links on MSVC with a bare exit 1. reld's Windows bridge is that same lld-link, so "extend the strip list to reld" (soldr#3262's plan) papers over a reld-visible failure. Phase 4 root-causes it before reld is exempted from proc-macro links.

Testing criteria

Every claim in this issue is backed by a test at one of five layers. A phase is done when its rows are green in CI on the hosts named; a row that cannot run on a host is reported as unavailable, never counted as passing (same rule as the #63 benchmark gate).

Layer Where What it proves Pass rule
Unit (routing) crates/reld-core/src/bridge.rs tests For a given argv (+ response files, + env), the selected engine, reason, forwarded argv, and stripped tokens are exactly as the flag table says One test per FlagRule, generated from the table; a rule without a test does not compile (#[test] name derived from the spelling)
Equivalence (satisfied-by-construction) crates/reld/tests/acceptance.rs, one sources/ fixture per rule Native output has the property the flag governs, equal to ld.lld's with the flag: archive-cycle resolution, DT_FLAGS, retained sections, .deplibs expansion, TEXTREL, RPATH tag Property comparison via reld-diff, not byte identity; the test names the rule, the rule names the test
Conformance (native semantics) mold external suite (external_test_suites/mold, mold_skip_tests.toml) and reld-difftest --reference-linker bfd Declared-Native flags behave like GNU ld/mold No skip entry may name a flag whose disposition is Native; the semantic-group skip count is published per run and must not increase (ratchet)
Corpus (coverage of real emitters) new ci/flag_corpus.py + ci/flag-corpus.lock.json, all three hosts Every token, input magic, and linker-script command any listed emitter produces matches exactly one rule; the selected route for each row is the expected one Unmatched token → red; unexpected route (checked with RELD_REQUIRE_ENGINE) → red; lock drift on toolchain bump → red until re-approved
End-to-end (consumers) ci/consumer_acceptance.py, ci/linker_modes.py, cross-ship.yml, linker-artifacts.yml Real Rust, C, C++, CMake projects build, run, and are self-deterministic on each host through the expected engine; the invocation log names the engine, resolved linker path, and version Existing gates plus: RELD_REQUIRE_ENGINE per project, RELD_STRICT_BRIDGE=1 on Windows/macOS, nix_rpath.rs under both RELD_ENGINE values

Specific criteria that must exist by name, so they can be checked off:

Routing and classification

Semantics that were silently wrong

Formats and hosts

  • windows_gnu_from_linux_links_and_runs (cross-ship, Phase 3) and windows_gnu_on_windows_routes_to_mingw_lld (Phase 4)
  • macho_from_linux_links: ld64.lld route produces a Mach-O the macOS runner executes (Phase 3)
  • macos_rustc_default_flavor_links: whichever Phase 3 chose, CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=reld with no extra rustflags builds the sqlite e2e (Phase 3)
  • strict_bridge_rejects_ignored_msvc_flags: /PROFILE under RELD_STRICT_BRIDGE=1 errors; without it, it is logged (Phase 4)
  • msvc_proc_macro_dll_links: the soldr#1992 shape (--crate-type proc-macro -C prefer-dynamic, 4 parallel) passes 20/20 through the bridge, or the root cause is documented and gated (Phase 4)

Bridge operation

  • discovery_never_installs_toolchains: with rust-toolchain.toml naming an uninstalled channel and RUSTUP_AUTO_INSTALL=0-equivalent assertions, a routed link resolves via RUSTC/RELD_BRIDGE_LINKER or fails loudly; never spawns a download (Phase 5)
  • bridged_child_signal_is_reported: a child killed by SIGSEGV yields exit 139 and a stderr line naming the engine (Phase 0) — done in test: pin the bridged-signal path and RUNPATH on both engines #156
  • jobserver_tokens_become_threads: under MAKEFLAGS with 2 tokens the bridged lld receives --threads=2 (Phase 5)
  • response_file_quoting: the mold response-file-quoting.sh case passes natively and the same file yields the same tokens in the classifier (Phase 5) — tokenizer unified in feat(core): single GNU response-file tokenizer and Phase 0 classifier tests (#179) #183; the mold case now parses natively and only differs in error wording, so it sits in the ignore group
  • reld_identity_never_bridges: on all three hosts --reld-identity prints reld's version with no child process (Phase 5)

Gate

  • flag_corpus job green on Linux, Windows, macOS with the full D4 matrix and lock file (Phase 6)
  • semantic-group ratchet count published and non-increasing over the last 10 main runs (Phase 6)
  • docs/flags.md, README, polylinker.md regenerated from the table with no manual edits (Phase 6)

Acceptance for this issue

  • No Ok(()) no-op flag handler exists outside the flag table; every table entry has a disposition with a test or probe reference; every entry in the vendored lld and GNU ld inventories has a rule.
  • Every capability an engine claims was measured by a probe in CI; lld's "ignored for compatibility" options are deviation-register entries, not capabilities.
  • Every token, input magic, and linker-script command in the emitter corpus matches exactly one rule, on all three CI hosts.
  • rustc's ordinary bin, cdylib, proc-macro, +crt-static, and -C link-dead-code links run natively under RELD_REQUIRE_ENGINE=reld with correct semantics; -C lto, -C linker-plugin-lto, and bitcode inputs route to lld under RELD_REQUIRE_ENGINE=lld.
  • SHF_GNU_RETAIN and .deplibs are honored natively with tests. -z text and -l:name work.
  • No mold skip-list entry names a flag whose table disposition is Native; the semantic-group ratchet count is published by the Phase 6 job.
  • Bridged links record the resolved lld path and version, honor the jobserver, and share one response-file grammar with the native parser.
  • Route invariance: nix_rpath.rs passes under RELD_ENGINE=lld; no reld-only option reaches a child linker; a signal-killed child is reported as a signal.
  • Every §K default has a deviation-register entry and, where it changed, an acceptance test.
  • i386/arm32/mips/s390x objects, windows-gnu, Mach-O, and COFF links from a Linux host route to a bundled lld engine rather than failing in the ELF parser.
  • reld --reld-identity never bridges.
  • Make reld the default linker for soldr soldr#3262 stays blocked until the Phase 6 job is green; its body is corrected for macOS, the clang probe, and identity.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions