Skip to content

Duplicate Rust-stdlib symbols across well-known native libraries when multiple 'shared tokio' libs are linked together #5928

Description

@proggeramlug

Summary

Found while getting a real-world sst/opencode compile all the way to a working binary (companion investigation to #5918/#5919, #5922/#5924/#5927/#5923). After all import/namespace-resolution symbol issues were fixed, the final link still failed — first with ~5900, then (after #5920/#5921 landed on main, which helped) ~2150 duplicate-symbol errors from ld on macOS.

Root cause

opencode's dependency graph needs FIVE separate "well-known" native libraries simultaneously: http/https/http2perry-ext-http, fastifyperry-ext-fastify, ioredisperry-ext-ioredis, netperry-ext-net, wsperry-ext-ws, each rebuilt "with shared tokio" per the well-known: routing log lines. Each of these is an independently-built Rust staticlib that bundles its OWN full copy of shared transitive dependencies (their exact codegen-unit object files, e.g. tokio-<hash>.tokio.<hash>-cgu.0.rcgu.o). libperry_stdlib.a ALSO bundles its own copies of the same crates (for its own fetch/https/websocket support). When multiple such libraries are linked together, every shared dependency they have in common — std, core, alloc, hashbrown, aho_corasick, regex_automata, tokio, hyper_util, h2, rustls, reqwest, ring, bytes, url, and perry's own perry_runtime codegen unit — ends up defined in more than one archive.

#5920/#5921 (already on main) added strip_bundled_runtime_from_well_known_lib, which safely drops duplicate perry_runtime-* member objects from a well-known lib when stdlib bundles the identical codegen unit (verified via two safety rules: Rule 1 — stdlib bundles the exact same codegen unit; Rule 2 — no sibling member in the same archive depends on a symbol only that copy provides). This fixed the perry_runtime duplicates specifically and reduced the failure from ~5900 to ~2150 duplicate symbols in opencode's case, but the SAME duplication shape recurs for every other shared crate above once TWO OR MORE well-known libraries both need a full HTTP-client stack (e.g. both http and fastify bundle tokio+hyper_util+h2+rustls+reqwest+ring).

Why there's no linker-flag escape hatch

Verified directly on this machine's toolchain (Xcode/CLT current as of 2026-07): neither -Wl,-multiply_defined,suppress nor -Wl,-ld_classic (with or without -multiply_defined) suppress the duplicate-symbol error anymore — both are reported as "obsolete"/"deprecated" and the link still fails. Unlike GNU ld/lld (which perry already uses -Wl,--allow-multiple-definition for on Android/HarmonyOS), Apple's current linker has no tolerance mode for this. The fix has to happen at the object/archive level before linking, mirroring what #5920/#5921 already does for perry_runtime.

What does NOT work: naively widening #5920's candidate set

I tried generalizing strip_bundled_runtime_from_well_known_lib's candidate filter from member.starts_with("perry_runtime-") to "every member" (relying on the existing Rule 1 + Rule 2 checks to gate safety). This broke the existing issue_5920_wrapper_bundled_runtime_async_starvation regression test (fire_and_forget_fetch_survives_recompile started failing with the same starvation symptom #5920 fixed).

Root cause of why the naive widening is unsafe: Rule 2's sibling_undefined set is computed as "the union of undefined symbol references from every member NOT in the candidate set." As the candidate set grows to include nearly every crate-derived member (std, core, tokio, etc. are present in almost every object in an HTTP-stack-bundling archive), the "sibling" set used to check for unsafe removals shrinks correspondingly — inter-candidate dependencies (candidate A referencing a symbol only candidate B defines, both of which are simultaneously being removed) are never checked at all, since Rule 2 only guards candidate-vs-non-candidate edges. A safe generalization needs either a smarter per-member reachability computation, or an iterative fixed-point approach (remove one "layer" of provably-safe duplicates at a time, recomputing sibling references between rounds) rather than a one-shot flat widening.

Suggested direction for a real fix

  • Generalize the duplicate-detection to run per-crate (matching each member's crate-name prefix, e.g. tokio-, hyper_util-, h2-, rustls-, reqwest-, ring-, bytes-, url-, std-, core-) similarly to perry_runtime-, but iterate to a fixed point (repeatedly compute removable members and re-derive sibling_undefined from the shrinking "kept" set) instead of a single pass, so inter-candidate dependencies are still caught.
  • Alternatively, dedupe at the ld-invocation level via -single_module/symbol-versioning-style tricks, or by post-processing with llvm-objcopy --keep-global-symbols per-archive against a computed "authoritative provider" map (whichever archive is linked first/is stdlib wins, in ALL other archives strip to only symbols not already provided elsewhere) — this needs the same fixed-point care as above.
  • This is a genuinely deep, strip_dedup.rs-architecture-level task; recommend whoever picks it up start from the #5920 test's design (spawn a real async task, assert it doesn't starve) and add an analogous "N well-known libs + fastify + http simultaneously" fixture to catch regressions in either direction.

Workaround used for now

None found that's both safe and general — @fastify/*/@ioredis/commands removal from compilePackages was tried and abandoned (it just routes those packages to interpreted-JS fallback, which this build of perry has removed support for entirely: "JavaScript runtime (V8) support has been removed"). opencode's full binary currently cannot complete linking with --enable-wasm-runtime and its real dependency set; all symbol-resolution issues (the actual multi-bug investigation across #5918/#5922/#5924/#5927) are fully fixed and this is the last remaining blocker.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions