Skip to content

fix(test): keep the runtime's symbols global in the ELF provider link - #8127

Merged
proggeramlug merged 4 commits into
mainfrom
fix/8089-elf-provider-runtime-exports
Aug 15, 2026
Merged

fix(test): keep the runtime's symbols global in the ELF provider link#8127
proggeramlug merged 4 commits into
mainfrom
fix/8089-elf-provider-runtime-exports

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

gc-native-roots is red on main, on both ELF platforms only (Mach-O and Windows pass), with:

Error: "stdlib provider is bound to a different runtime image"

Established by experiment, not inference

I re-ran the last-good job on its own commit: 5c27d1ad9 passes today. So the environment is fine and the gate is not flaky — the break is real and belongs to the next commit, #8089, which introduced the ELF branch of this linker shim.

Root cause

The ELF branch writes a version script listing the 16 provider exports and then local: *.

The provider statically links the runtime rlib and loads the runtime .so, so it carries its own definition of js_gc_init and friends. local: * binds those internally, and a local symbol is not preemptible — so the stdlib stops resolving stateful runtime calls to the image the host loaded first. That is precisely the condition the fixture exists to detect (probe() != api.gc_init), and it detected it correctly.

The Mach-O branch never had this problem because it re-exports the runtime's whole symbol set (nm -gU "$runtime_library") alongside the provider exports. The two branches were asymmetric.

Fix

Do the same for ELF. local: * still hides everything else, so #8089's intent — export the Web Fetch/Streams surface the later-loaded app needs, and nothing more — is preserved.

One deliberate difference: nm -D --defined-only rather than nm -gU. Preemption is governed by the dynamic symbol table, not the static symtab that a stripped .so need not carry at all. (Plain nm -gU is correct for Mach-O, where that is the table plain nm reads.)

Validation

Shell syntax checked, and the awk emits well-formed version-script entries ( name;). The real oracle is this PR's own gc-native-roots run — the failure is ELF-only and cannot be reproduced on a macOS host, which is exactly how it reached main.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a regression affecting runtime exports from statically linked providers.
    • Resolved linker errors involving undefined symbol versions.
    • Preserved correct global symbol behavior while ensuring provider runtime symbols remain available.
  • Documentation
    • Added release notes describing the export regression, its symptoms, and the implemented fix.

proggeramlug pushed a commit that referenced this pull request Aug 15, 2026
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b7326fd2-7f70-4b84-80a7-e78bcd377dd0

📥 Commits

Reviewing files that changed from the base of the PR and between d6d7d0e and a1c4786.

📒 Files selected for processing (2)
  • changelog.d/8127-elf-provider-runtime-exports.md
  • tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh

📝 Walkthrough

Walkthrough

The ELF provider version script now explicitly exports the 16 provider symbols without applying local: *. The changelog documents the regression, symbol-binding cause, and --no-undefined-version constraint.

Changes

ELF provider export binding

Layer / File(s) Summary
Correct provider export bindings
tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh, changelog.d/8127-elf-provider-runtime-exports.md
The generated version script removes local: * and keeps the selected provider symbols explicitly exported. The changelog documents the runtime symbol resolution behavior and linker constraints.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/8089-elf-provider-runtime-exports

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 3 commits August 15, 2026 15:50
`gc-native-roots` is red on main, on both ELF platforms only, with

    Error: "stdlib provider is bound to a different runtime image"

Mach-O and Windows pass. Bisected by re-running the last-good job on its own
commit: 5c27d1a passes today, so the environment is fine and the gate is not
flaky. The break is the next commit, #8089, which added the ELF branch of this
linker shim.

That branch writes a version script listing the 16 provider exports and then
`local: *`. The provider statically links the runtime rlib as well as loading
the runtime `.so`, so it carries its own definition of `js_gc_init` and
friends, and `local: *` binds those internally — a local symbol is not
preemptible. The stdlib therefore stops resolving stateful runtime calls to the
image the host loaded first, which is exactly the condition this fixture exists
to detect, and it duly detected it.

The Mach-O branch never had the problem because it re-exports the runtime's
whole symbol set (`nm -gU "$runtime_library"`) alongside the provider exports.
Do the same for ELF. `local: *` still hides everything else, so #8089's intent
— export the Web Fetch/Streams surface the later-loaded app needs, and nothing
more — is preserved.

`nm -D --defined-only` rather than the Mach-O branch's `nm -gU`: preemption is
governed by the dynamic symbol table, not the static symtab that a stripped
`.so` need not carry at all.
Second attempt; the first was wrong in a way CI caught.

The failure is real: `gc-native-roots` is red on both ELF platforms only, with
"stdlib provider is bound to a different runtime image". Re-running the
last-good job on its own commit shows that commit still passes, so the
environment is not at fault — the break came with the ELF branch of this shim
added in #8089.

The cause is the `local: *` in the version script that branch writes. The
provider statically links the runtime rlib as well as loading the runtime
`.so`, so it carries its own `js_gc_init` and friends; `local: *` binds those
internally, and a local symbol is not preemptible. The stdlib then resolves
stateful runtime calls to its OWN copy rather than the image the host loaded
first — precisely the condition the fixture detects. Before this shim parsed
`--version-script` at all, the rustc-generated script was passed through and
the gate passed: the regression is the hiding, not the export list.

My first fix mirrored the Mach-O branch and re-exported the runtime's whole
symbol table via `nm`. That cannot work here: rustc also passes
`--no-undefined-version`, so naming a symbol the output does not define is a
hard lld error, and the link died with hundreds of "version script assignment
of 'global' to symbol ... failed: symbol not defined". ld64 only warns, which
is why the Mach-O branch gets away with it.

Drop the `local: *` instead. Every symbol not named keeps its default global,
preemptible binding — restoring the pre-#8089 behaviour — while the 16 provider
exports are still named explicitly, so #8089's actual goal (the later-loaded
app resolves the Web Fetch/Streams surface) is unchanged.
@proggeramlug
proggeramlug force-pushed the fix/8089-elf-provider-runtime-exports branch from cb3d164 to cb7b49a Compare August 15, 2026 13:50
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Independently verified on real ELF, outside CI — this PR's head does fix it

The PR body says "the real oracle is this PR's own gc-native-roots run — the failure is ELF-only and cannot be reproduced on a macOS host." It can be reproduced off-CI, in about a minute, and I did.

Method. Linux/aarch64 container (rust:1-bookworm under Docker on an arm64 mac). Four copies of this repository's actual tests/fixtures/issue_8075_provider_gc/stdlib-linker.sh, taken verbatim with git show <ref>:…, each used as CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER for a cargo build --profile provider of a cdylib that mirrors the real provider: extern crate perry_stdlib, the same #[used] pins, the same unsafe extern "C" { fn js_gc_init(); } probe. perry-runtime/perry-stdlib are three-file stand-ins (the runtime is patched crate-type = ["dylib"] and built into a separate provider .so exactly as gc_provider_dylib_gate.sh does, so the rlib copy is pulled for the same reason it is in the real build). Then the same check host.rs runs: dlopen(runtime, RTLD_GLOBAL), dlopen(stdlib, RTLD_GLOBAL), probe() == dlsym(runtime, "js_gc_init").

This is not the full gate — no Perry compiler, no app dylib, no GC assertions. It exercises exactly the chain this PR changes: rlib-vs-.so link order, the version script, and symbol preemption at load.

stdlib-linker.sh from js_gc_init in stdlib .dynsym stdlib exports shared with runtime provider .so size probe
5c27d1ad9 (last good) T global 19 2 3 877 240 OK
origin/main (#8089) absent — demoted to local t 16 0 3 877 240 Error: "stdlib provider is bound to a different runtime image"
this PR @ cb7b49abf T global 894 871 4 008 312 OK
drop the interception entirely T global 19 2 3 877 240 OK

Every claim in your root-cause section checks out, including the one that is easy to get wrong: --no-undefined-version really is passed by rustc immediately after its own --version-script, so naming the runtime's symbols explicitly (your first commit) is a hard error and not merely wasteful. Confirmed by dumping the cdylib link line.

Two things the numbers add:

1. rustc's own version script already lists every #[no_mangle] symbol in the crate graph, upstream rlibs included. Measured on a two-crate toy: an rlib's #[no_mangle] js_gc_init / js_headers_new appear in global: of the script rustc generates for the dependent cdylib, alongside the cdylib's own symbols, above local: *. That is why 5c27d1ad9 was green — the bundled js_gc_init stayed a global dynsym entry and was preempted by the earlier-loaded provider — and it means all 16 names in stdlib_provider_exports are already in rustc's list (all 16 are #[no_mangle] in perry-stdlib/perry-runtime; I checked each). The #[used] pins from #8089 are what makes them defined; the hand-written script never added an export that rustc was not already granting.

2. global: with no local: * does not restore the previous surface — it removes all hiding. 19 → 894 exports here, and 871 of them are now shared with the runtime provider, i.e. newly preemptible across the two images. They are not the fetch surface; they are std/panic/alloc internals (v0-mangled, so a ^_ZN grep reports zero — mine did at first):

_RNvCs9wFQrvczXsK_7___rustc12___rust_alloc
_RNvCs9wFQrvczXsK_7___rustc10rust_panic
_RINvNtCsjfZfl7cBLOC_3std9panicking11begin_panicReEB4_
_RINvNtCsfo0Ls1ZyrQZ_4core9panicking13assert_failedllECsjfZfl7cBLOC_3std
_RINvMs3_NtNtCsjfZfl7cBLOC_3std2io5errorNtB6_5Error3newReEBa_

libstd is the same rlib for both images, so these hash-match and really will bind to the runtime provider at load. The provider profile is panic = "abort" while the runtime dylib is built at perry-dev; after this change the stdlib provider's panic/alloc entry points resolve into the other image. That is probably harmless and may even be desirable, but it is a live change to cross-image linkage inside the one fixture whose subject is cross-image runtime state — a configuration nobody has measured. Exported symbols are also --gc-sections roots, so nothing in the provider can be discarded any more (+3.4 % here on a toy; unknown on the real 340k-line runtime).

Suggestion: delete the interception instead

Passing rustc's script through untouched restores the last-good export surface exactly — 19 exports, the same 2 shared with the runtime provider, byte-identical output size — and is a deletion, so there is no new invariant to maintain:

@@
     -Wl,-exported_symbols_list,*)
       original_export_list=${argument#-Wl,-exported_symbols_list,}
       ;;
-    -Wl,--version-script=*)
-      original_version_script=${argument#-Wl,--version-script=}
-      ;;
     *) arguments+=("$argument") ;;
   esac
 done
@@
-if [[ -n "$original_version_script" ]]; then
-  if [[ "$saw_runtime_rlib" == true ]]; then
-    custom_version_script=$(mktemp "${TMPDIR:-/tmp}/perry-8075-version.XXXXXX")
-    {
-      echo '{ global:'
-      printf '  %s;\n' "${stdlib_provider_exports[@]}"
-      echo 'local: *; };'
-    } > "$custom_version_script"
-    arguments+=("-Wl,--version-script=$custom_version_script")
-  else
-    arguments+=("-Wl,--version-script=$original_version_script")
-  fi
-fi

(plus dropping the now-unused original_version_script / custom_version_script variables and their cleanup line; stdlib_provider_exports stays, the Darwin branch still needs it, because ld64's -exported_symbols_list genuinely does have to be narrowed and re-widened.)

Either way, this class should not have to travel through a 90-minute gate to a runtime error string again. A link-time assertion in gc_provider_dylib_gate.sh, next to the existing readelf -d check, would name it at the point it happens:

nm -D "$stdlib_library" | grep -q ' js_gc_init$' || {
  echo "stdlib provider hides js_gc_init: the bundled runtime copy is not preemptible" >&2
  exit 1
}

Happy to open that as a follow-up, or fold it in here — say which.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant