Skip to content

[native_toolchain_c] Only keep the symbols the input archives define - #3726

Open
mosuem wants to merge 6 commits into
mainfrom
ntc-windows-export-defined-symbols
Open

mosuem wants to merge 6 commits into
mainfrom
ntc-windows-export-defined-symbols

Conversation

@mosuem

@mosuem mosuem commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

LinkerOptions.treeshake asks the linker to keep every symbol in symbolsToKeep. If one of them isn't defined by the inputs, that fails: on Windows the generated module-definition file exports it (LNK2001); on macOS and iOS -u requires it (ld64: "must be defined for the link to succeed"); on Linux and Android -u leaves an undefined dynamic symbol, which fails at load time with -z now. Tree-shaking asks for such symbols easily, for example when bindings cover functions that a particular build or target leaves out, so package:boring filters its symbols itself today.

With this PR, CLinker.run restricts the symbolsToKeep to the symbols that the input archives define before generating the linker flags (LinkerOptions.withSymbolsDefinedIn). The flags themselves don't change.

  • readArchiveSymbols reads only the symbol table of an archive: the first linker member of COFF .lib files, which has the same format as System V/GNU .a files, and the BSD __.SYMDEF [SORTED] (or __.SYMDEF_64) table of macOS and iOS archives, including the #1/<length> names that Apple's tools use. Checked against llvm-nm --print-armap for archives from llvm-lib (x64, and x86 with _-prefixed and C++ names), GNU ar, and llvm-ar in gnu, bsd and darwin format.
  • Mach-O symbols and C symbols on x86 Windows have a leading underscore, which is matched.
  • The skipped symbols are logged. If none of the symbols is defined, most likely a wrong prefix, it's a warning.
  • If an input isn't an archive with a symbol table, for example an object file, nothing is filtered, so behavior is the same as before. null (keep all) and an empty list (skip the library) are unchanged too.

Behavior change: a symbol in symbolsToKeep that only a library in libraries defines, not one of the sources, is no longer kept. Those are usually system libraries, so I don't expect this to matter, but I'm happy to handle it differently.

Also bumps the version to 0.19.6, so that this fix and #3718's can be published after merging.

Tests: host-independent unit tests for the archive reader (COFF/GNU and BSD, 32- and 64-bit, truncated and malformed tables), for withSymbolsDefinedIn and the flags it leads to on each OS, and for the warning. The tree-shaking integration tests on every OS, including the Android and iOS cross-compilation ones, and the Windows module-definition test for every Windows architecture now also ask for an undefined symbol.

This lets package:prebuilt_code_assets (#3719) and package:boring drop their own archive parsing.

Fixes #3737.


  • I've reviewed the contributing guide and applied the relevant portions to this PR.
Contribution guidelines:

Note that many Dart repos have a weekly cadence for reviewing PRs - please allow for some latency before initial review feedback.

… Windows

LinkerOptions.treeshake lists all symbolsToKeep in the generated
module-definition file, and link.exe fails if one of them is not defined.
Read the symbol tables of the input archives and only export the symbols
they define.
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

PR Health

Breaking changes ✔️
Package Change Current Version New Version Needed Version Looking good?
native_toolchain_c Non-Breaking 0.19.5 0.19.6 0.19.6 ✔️

This check can be disabled by tagging the PR with skip-breaking-check.

API leaks ✔️

The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.

Package Leaked API symbol Leaking sources

This check can be disabled by tagging the PR with skip-leaking-check.

Changelog Entry ✔️
Package Changed Files

Changes to files need to be accounted for in their respective changelogs.

This check can be disabled by tagging the PR with skip-changelog-check.

@codecov

codecov Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.99%. Comparing base (f7ffe96) to head (3e179ba).
⚠️ Report is 28 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3726      +/-   ##
==========================================
+ Coverage   87.93%   87.99%   +0.05%     
==========================================
  Files         310      311       +1     
  Lines       19799    19894      +95     
==========================================
+ Hits        17411    17506      +95     
  Misses       2388     2388              
Flag Coverage Δ
native_pkgs_macos 86.81% <100.00%> (+0.47%) ⬆️
native_pkgs_ubuntu 72.85% <100.00%> (+0.88%) ⬆️
native_pkgs_windows 75.84% <100.00%> (+0.52%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
code_assets 94.96% <ø> (ø)
data_assets 91.56% <ø> (ø)
ffi 100.00% <ø> (ø)
ffigen 91.58% <ø> (ø)
hooks 86.40% <ø> (ø)
hooks_runner 89.72% <ø> (ø)
jni 66.34% <ø> (ø)
jni_flutter ∅ <ø> (∅)
jni_util 25.00% <ø> (ø)
jnigen 82.85% <ø> (ø)
json_syntax_generator ∅ <ø> (∅)
native_test_helpers 85.71% <ø> (ø)
native_toolchain_c 96.18% <100.00%> (+0.24%) ⬆️
objective_c 79.53% <ø> (ø)
pub_formats ∅ <ø> (∅)
record_use 81.48% <ø> (ø)
snippet_tool 66.23% <ø> (ø)
swift2objc 92.95% <ø> (ø)
swiftgen 80.41% <ø> (ø)
test_case_selector 97.28% <ø> (ø)
Files with missing lines Coverage Δ
...s/native_toolchain_c/lib/src/cbuilder/clinker.dart 87.71% <100.00%> (+0.68%) ⬆️
...e_toolchain_c/lib/src/cbuilder/linker_options.dart 95.57% <100.00%> (+1.38%) ⬆️
...ive_toolchain_c/lib/src/cbuilder/run_cbuilder.dart 97.84% <100.00%> (ø)
...ive_toolchain_c/lib/src/utils/archive_symbols.dart 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Package publishing

If you have publishing permissions, you can use the links below to publish the changes after merging this PR.

Package Version Status Publish tag (post-merge)
package:native_toolchain_c ⭐ 0.19.6 ready to publish native_toolchain_c-v0.19.6
  • 7 already published.
  • 4 WIP (no publish necessary).

Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.

…l is defined

LinkerOptions.manual passes an /INCLUDE: flag per symbol to keep, which
fails with LNK2001 for an undefined symbol just like an export in the
generated module-definition file does, so filter both to the symbols the
input archives define.

If none of the symbols to keep is defined, the library would export nothing,
which is almost always a mistake such as a symbol prefix the archives don't
have, so log a warning instead of the info message that lists the skipped
symbols.
@mosuem
mosuem marked this pull request as ready for review October 9, 2026 12:18
@mosuem
mosuem requested a review from dcharkes October 9, 2026 12:18
…very OS

macOS and iOS have the same problem as Windows: `-u` makes the link fail if
the symbol isn't defined (ld64: "must be defined for the link to succeed").
On Linux and Android, `-u` of an undefined symbol leaves an undefined dynamic
symbol in the library, which fails at load time with `-z now`.

readArchiveSymbols now also reads the BSD symbol table of macOS and iOS
archives (`__.SYMDEF [SORTED]`, `__.SYMDEF_64 [SORTED]`, with the `#1/<len>`
member names of Apple's tools), checked against llvm-ar --format=darwin and
llvm-nm --print-armap, and the filter applies to the `-u` flags, the exported
symbols list and the version script, matching the leading underscore of
Mach-O. The tree-shaking tests ask for an undefined symbol on every OS.

Fixes #3737
@mosuem mosuem changed the title [native_toolchain_c] Only export symbols the input archives define on Windows [native_toolchain_c] Only keep the symbols the input archives define Oct 9, 2026
mosuem added 2 commits October 9, 2026 12:34
Filtering inside the two flag-generating functions made the reader track the
requested and the kept symbols through both, and threaded the architecture
and logger through every signature. LinkerOptions.withSymbolsDefinedIn now
returns restricted options, which CLinker.run computes once from the
resolved sources; the flag generation is unchanged from before. The sources
are host paths there, so this also works when the linker runs through WSL.
Archives built on macOS for Linux and Android were created with `rcS`, that
is without a symbol table, because Apple's ranlib can't index ELF objects
(#3437). The NDK's and LLVM's ar can, and the symbol table is what
withSymbolsDefinedIn reads, so without it the Android cross-compilation
tests on macOS asked the linker for the undefined symbol again. Omit the
symbol table only when the archiver is Apple's ar.
@dcharkes

Copy link
Copy Markdown
Collaborator

Tree-shaking asks for such symbols easily, for example when bindings cover functions that a particular build or target leaves out

This is a variant of OS-specific bindings issue in FFIgen:

The current workaround is to generate bindings per host. Which is not great. Now, if some packages have a subset of symbols, it's probably desirable to generate simply the superset.

If we had a proper solution for this, the record-use wouldn't report bogus symbols, and we wouldn't have to filter before passing to the native linker. (A proper solution probably requires conditional Dart compilation based on OS. 🤷 )

Until we have that we can land this workaround.

=> Please add a reference to the github issue of why FFIgen (and similar bindings) could contain symbols not in the native libs.

Now one thing that is kind of surprising to me is that TFA does not manage to tree-shake out the symbols that don't exist at runtime. For sure you're not calling them, that would fail dlsym at runtime. But in your programs TFA can't prove that the symbols are not reachable?

=> Would it be beneficial to have hard if (Platform.isXxx) throw UnreachableError() in the code? Then TFA should for sure be able to prove that you can never reach the symbols, e.g. it will think that you will reach the throw. What symbols does TFA think are reachable? Where are they in the codebase in package:boring?

pkgs/native_toolchain_c/lib/src/utils/archive_symbols.dart

Magic. rubberstamp llm generated magic! 🙃

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[native_toolchain_c] LinkerOptions.treeshake fails to link on macOS and iOS if a symbol to keep is not defined

2 participants