Skip to content

fix(workbench): hide every symbol but the UniFFI exports in the iOS static libraries - #2968

Merged
zancas merged 6 commits into
devfrom
fix/ios-hide-static-symbols
Oct 15, 2026
Merged

zancas merged 6 commits into
devfrom
fix/ios-hide-static-symbols

Conversation

@zancas

@zancas zancas commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Problem

The Swift wire-contract test of PR #2937 links both iOS static libraries into one test bundle. The linker prints 441 duplicate symbol warnings. Each library carries its own copy of Rust's std, core and alloc. Every consumer that links both libraries sees the same warnings.

One static library cannot hold both crates. The wallet crate and the proxy crate build in separate workspaces, because nym-sdk and the zcash crates hold conflicting dependencies on crypto-common.

Change

The iOS plan hides every symbol of each library except its UniFFI functions. It does this per target, after the bindgen step and before lipo and the XCFramework steps.

  1. ld -r -force_load links the cargo archive into one relocatable object. Two -exported_symbol patterns on the command, _uniffi_<lib>_* and _ffi_<lib>_*, keep the UniFFI functions global. The linker marks every other global symbol private extern.
  2. libtool -static packs that object over the cargo archive.

The link names its architecture and platform. Xcode 16.4's linker refuses an object with no -arch, so the plan passes -arch and -platform_version per target. One IosTarget table holds the triple, the architecture and the platform of each target. The plan, the toolchain pin and the tests all read that table.

The bindgen step runs before the hide, so it reads the raw symbol table. Because the hidden archive takes the cargo archive's path, lipo and the XCFramework steps do not change.

A Generation now knows its library name. The hide step takes one Generation and derives the target directory, the archive and the export patterns from it.

Tests

A plan test asserts the link and pack commands of every target, with their architecture, platform and export patterns. It asserts that the bindgen step precedes the link, that the link precedes the pack, and that every later step that reads the archive follows the pack.

Verification

cargo fmt, cargo check --all-targets, cargo clippy --all-targets -- -D warnings and cargo nextest run pass on the workbench crate.

The per-PR iOS job on 991bcb5 built both XCFrameworks and linked the Swift contract test bundle. Its log holds no duplicate symbol warning, and the test reports Executed 3 tests, with 0 failures. That link proves the -exported_symbol wildcards and the in-place overwrite on Xcode 16.4.

🤖 Generated with Claude Code

zancas and others added 5 commits October 14, 2026 15:59
…tatic libraries

The Swift wire-contract test links both iOS static libraries into one test
bundle, and the linker prints 441 duplicate-symbol warnings. Each library
carries its own copy of Rust's std, core and alloc, because the wallet crate
and the proxy crate build in separate workspaces: nym-sdk and the zcash
crates hold conflicting dependencies on crypto-common, so one static library
cannot hold both. Every consumer that links both libraries sees the same
warnings.

The iOS plan now hides every symbol of each library except its UniFFI
functions before lipo and the XCFramework steps. For each target the plan
writes an exported-symbols list naming the library's uniffi_ and ffi_
functions, links the cargo archive into one relocatable object with ld -r
and -exported_symbols_list, which marks every other global symbol private
extern, and packs that object into an archive with libtool. The bindgen step
still reads the cargo archive, because UniFFI's library mode finds its
metadata through the symbol table. The universal simulator library and both
XCFrameworks take the hidden archives.

A unit test pins the exported-symbols text of each library, and a plan test
asserts the link and archive steps of every target, their order after the
bindgen step, and that lipo and the XCFramework steps read only hidden
archives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…OS link

The relocatable link that hides every symbol but the UniFFI exports
ran `ld -r` without naming an architecture. Xcode 16.4's linker does not
infer one from the inputs in that mode: it warns that no `-arch` was
given, discards the archive as an "iOS-unknown" versus "iOS-arm64"
mismatch, and then asserts "unknown objc arch" in its Objective-C pass.
That assertion failed the per-PR iOS job on run 38651288550.

Each iOS target now carries its linker architecture and platform in an
IosTarget value, and the link passes `-arch` and `-platform_version`
derived from it. The device target links as arm64 on ios, and both
simulator targets link on ios-simulator as arm64 and x86_64. The
deployment target serves as both the minimum and the SDK version, as
rustc does for its own Apple links. The existing triple constants and
the target iterator derive from the same values, so no other step
changes.

The plan test pins the literal link command for every triple from a
three-row table of triple, architecture, and platform, and checks that
the table covers every planned target.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…o side files

The hide step now passes its two UniFFI patterns to ld as -exported_symbol
flags. The exports file, the Write step and the test that pinned the file's
text are gone. The plan test pins the whole link command instead.

The libtool step writes the hidden archive over cargo's archive. The bindgen
step already runs before the hide, so it reads the raw symbol table, and the
lipo and XCFramework steps return to their unchanged paths. The hidden
directory and its closures are gone. The plan test asserts that every later
reader of the archive follows the pack step.

A Generation now names its own library, so the hide step takes one Generation
and derives the target directory, the archive and the patterns from it. The
LIB_NAMES array is gone.

The IosTarget table is the only description of the three targets. The two
triple constants, the iter_targets helper and the test's parallel table of
triples, architectures and platforms are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread tools/workbench/src/bin/build-binding-layer.rs Outdated

@juanky201271 juanky201271 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI confirms the fix: dev's iOS job (after #2937) prints 441 duplicate symbol warnings, this branch prints 0.

  • The description predates a686a40: there's no exported-symbols list file (the flags are inline -exported_symbol), no hidden/ dir (libtool overwrites cargo's archive in place), and no unit test for exported_symbol_patterns. #2937 is already merged, so the Verification section is outdated too.
  • Overwriting cargo's .a in place: fresh_plan only cleans out_host, so on a local rerun cargo sees the archive as fresh and bindgen reads the already-hidden one. Probably harmless, but writing to a separate path avoids relying on it.
  • The new consts, IosTarget, exported_symbol_patterns and lib_name have no /// docs, unlike their neighbours.
  • Re IosTarget: I'd keep the explicit fields. x86_64-apple-ios is a simulator without -sim, so the platform can't be cleanly parsed from the triple.

juanky201271
juanky201271 previously approved these changes Oct 15, 2026
…rm from one enum

A review of PR #2968 asked why IosTarget stored a triple beside two facts
that the triple already implies. The struct is now an enum with a Device
variant and a Simulator variant that carries its architecture. Three const
getters derive the rustc triple, the linker architecture and the linker
platform, so each fact about a target lives in one match arm. The device
constant and the four string constants are gone, and the plan test compares
a target to IosTarget::Device by equality.

The simulator architecture rides as data rather than as two flat variants
because the duplication gate treats a three-arm match from an enum to string
literals as a structural twin of Profile::directory. The payload also states
the real structure: one device target, and one simulator platform with two
architectures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

3 participants