From 7f7e93749f0c7fcb3cc915d8ff88364f865511c4 Mon Sep 17 00:00:00 2001 From: Marvin Hansen Date: Tue, 11 Aug 2026 15:00:29 +0800 Subject: [PATCH 1/4] test(rustdoc): reproduce doc test link failure against cc_toolchain runtime libs Adds a failing analysis test for `rust_doc_test` under a cc_toolchain that supplies its C++/unwind runtime through `static_runtime_lib`. NOTE: this test fails on its own. It is committed separately to record the reproduction; the fix follows in the next commit. What breaks ----------- On a toolchain enabling `static_link_cpp_runtimes`, doc tests fail to link: ld.lld: error: undefined symbol: _Unwind_Resume >>> referenced by alloc.rs:0 ... liballoc-*.rlib ... and the rest of the _Unwind_* family `rust_library` / `rust_binary` / `rust_test` are unaffected, which makes the failure look toolchain-specific when it is not. The test extends the fixture added in #3741, which already declares a cc_toolchain with `static_runtime_lib = ":dummy.a"` and the `static_link_cpp_runtimes` feature enabled -- exactly the configuration needed to observe this. It asserts that the doc test's rustdoc action names the runtime lib on the command line (`-Clink-arg=-ldummy`). Today it does not. The action's argv shows the search path arriving while the library reference never does: "-Lnative=test/unit/cc_toolchain_runtime_lib", <- present ... <- no -Clink-arg=-ldummy That asymmetry is the bug: `add_native_link_flags` emits the runtime libs' `-Lnative=` unconditionally but gates the matching `-lstatic=` behind `include_link_flags`, which rustdoc sets to False (#2467). #4080 added a compensating `-Clink-arg=-l` loop for doc tests, but it iterates only `dep_info.transitive_noncrates`, which never contains the toolchain runtime libs: Bazel injects those into C++ link actions, and rustdoc never runs one -- it drives the doc test link itself. The archives end up on the search path with nothing referencing them. A system GNU toolchain hides this, because the `-lgcc_s` rustc emits resolves to a real libgcc_s carrying the `_Unwind_*` symbols. Toolchains shipping their own unwinder tend to make `-lgcc_s` / `-lunwind` resolve to empty stub archives so third-party build systems do not fail on a missing library, which is what turns the omission into a link error. --- .../cc_toolchain_runtime_lib_test.bzl | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/test/unit/cc_toolchain_runtime_lib/cc_toolchain_runtime_lib_test.bzl b/test/unit/cc_toolchain_runtime_lib/cc_toolchain_runtime_lib_test.bzl index b097e1faf0..8ccc98fa7e 100644 --- a/test/unit/cc_toolchain_runtime_lib/cc_toolchain_runtime_lib_test.bzl +++ b/test/unit/cc_toolchain_runtime_lib/cc_toolchain_runtime_lib_test.bzl @@ -7,7 +7,7 @@ load("@rules_cc//cc:cc_toolchain_config_lib.bzl", "feature") load("@rules_cc//cc:defs.bzl", "cc_toolchain") load("@rules_cc//cc/common:cc_common.bzl", "cc_common") load("@rules_cc//cc/toolchains:cc_toolchain_config_info.bzl", "CcToolchainConfigInfo") -load("//rust:defs.bzl", "rust_shared_library", "rust_static_library") +load("//rust:defs.bzl", "rust_doc_test", "rust_library", "rust_shared_library", "rust_static_library") def _test_cc_config_impl(ctx): config_info = cc_common.create_cc_toolchain_config_info( @@ -81,6 +81,53 @@ inputs_analysis_test = analysistest.make( }, ) +def _rustdoc_link_args_analysis_test_impl(ctx): + env = analysistest.begin(ctx) + tut = analysistest.target_under_test(env) + + actions = tut[DepActionsInfo].actions + action = None + for candidate in actions: + if candidate.mnemonic in ["RustdocTestWriter", "RustdocTestCompile"]: + action = candidate + break + + asserts.true( + env, + action != None, + "error: no rustdoc test action found among: {}".format( + [candidate.mnemonic for candidate in actions], + ), + ) + + if action: + for expected in ctx.attr.expected_args: + asserts.true( + env, + expected in action.argv, + "error: expected '{}' in the rustdoc test link args: '{}'".format( + expected, + action.argv, + ), + ) + + return analysistest.end(env) + +rustdoc_link_args_analysis_test = analysistest.make( + impl = _rustdoc_link_args_analysis_test_impl, + doc = """An analysistest to examine the link args of a rust_doc_test target. + + rustdoc drives the doc test link itself rather than running a Bazel C++ + link action, so the cc_toolchain's runtime libs never arrive through + `static_link_cpp_runtimes` the way they do for a rust_library. They have to + be named explicitly on the rustdoc command line instead, or the doc test + fails to link against a toolchain that supplies its own unwinder. + """, + attrs = { + "expected_args": attr.string_list(), + }, +) + def runtime_libs_test(name): """Produces test shared and static library targets that are set up to use a custom cc_toolchain with custom runtime libs. @@ -150,3 +197,33 @@ def runtime_libs_test(name): target_under_test = "%s/_static_library" % name, expected_inputs = ["dummy.a"], ) + + # A doc test links like a binary, so it needs the static runtime lib named + # on the command line. `dummy.a` yields `-ldummy` via `get_lib_name`. + rust_library( + name = "%s/__doctest_library" % name, + edition = "2018", + srcs = ["lib.rs"], + tags = ["manual", "nobuild"], + ) + + rust_doc_test( + name = "%s/__doc_test" % name, + crate = ":%s/__doctest_library" % name, + tags = ["manual", "nobuild"], + ) + + with_extra_toolchain( + name = "%s/_doc_test" % name, + extra_toolchain = ":%s/test_cc_toolchain" % name, + target = "%s/__doc_test" % name, + tags = ["manual"], + # rust_doc_test is a test rule, so it is testonly. + testonly = True, + ) + + rustdoc_link_args_analysis_test( + name = "%s/doc_test" % name, + target_under_test = "%s/_doc_test" % name, + expected_args = ["-Clink-arg=-ldummy"], + ) From a42c84be0ceb62de688e0a66de27ccc0ab008869 Mon Sep 17 00:00:00 2001 From: Marvin Hansen Date: Tue, 11 Aug 2026 15:00:29 +0800 Subject: [PATCH 2/4] fix(rustdoc): link doc tests against the cc_toolchain's runtime libs Fixes the reproduction added in the previous commit: `//test/unit/cc_toolchain_runtime_lib:runtime_libs_test/doc_test` now passes. Two parts, both required. 1. `rustdoc.bzl` extends the #4080 `-Clink-arg=-l` loop to the cc_toolchain runtime libs, mirroring the crate-type split in `collect_inputs` so the libs it names are the ones that were added to the action inputs. Guarded on `cc_toolchain` being present, since #3665 made it optional. The libs are returned in the action struct for use by (2). 2. `rustdoc_test.bzl` adds those libs' root to the `--strip_substring` list. Without this the `-l` alone still fails with "unable to find library": the runtime libs are built in a different configuration than the crate outputs, so their root is not among the crate roots already collected, and the `-Lnative=` path stays an execroot path that does not exist in the runfiles tree the doc test runs from. Roots are only added when non-empty. Source-file runtime libs have an empty root and need no stripping -- their `-Lnative=` path is already workspace-relative -- and an empty root would emit `--strip_substring=/`. `rustdoc_test_writer` applies these as plain `str::replace`, so that would delete every `/` in every argument. The libs already reach the doc test's runfiles via `ctx.runfiles(transitive_files = action.inputs)`, thanks to #3741, so no additional wiring is needed there. Verified on macOS with `bazel test -- //... -//test/unit/remap_path_prefix:integration_test`: 569 tests pass and 35 are skipped, against 568/35 before this series. The only target that changed status is the new doc test. Every other action was a cache hit, so the change is a provable no-op for toolchains whose `static_runtime_lib` is empty. --- rust/private/rustdoc.bzl | 32 ++++++++++++++++++++++++++++++++ rust/private/rustdoc_test.bzl | 15 +++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/rust/private/rustdoc.bzl b/rust/private/rustdoc.bzl index 4c407755f9..cc0a23e5c7 100644 --- a/rust/private/rustdoc.bzl +++ b/rust/private/rustdoc.bzl @@ -144,6 +144,10 @@ def rustdoc_compile_action( # back to the crate root, which is always a `File` per the provider contract. rustdoc_crate_info = _rustdoc_crate_info(crate_info, output if output != None else crate_info.root) + # Runtime libs contributed by the cc_toolchain, tracked so `rust_doc_test` + # can strip their (separately configured) root from the runfiles paths. + static_runtime_libs = [] + # rustdoc does not understand linker flags like -lstatic that # `include_link_flags` generates. So we manually build flags that only apply # to rustdoc. @@ -170,6 +174,33 @@ def rustdoc_compile_action( else: rustdoc_flags.append("-Clink-arg=%s" % arg) + # The cc_toolchain's runtime libs (libc++ / libunwind on any toolchain + # enabling `static_link_cpp_runtimes`) are NOT part of + # transitive_noncrates: Bazel injects them into C++ link actions, and + # rustdoc never runs one -- it drives the link itself. + # `add_native_link_flags` emits their `-Lnative=` search path + # unconditionally but gates the matching `-lstatic=` behind + # `include_link_flags`, which is False for rustdoc. Without the `-l` + # below the archives sit on the search path with nothing referencing + # them, and every doc test fails to link with undefined `_Unwind_*`. + # + # Mirrors the crate-type split in `collect_inputs`, so the libs named + # here are the ones that were added to the action inputs. + if cc_toolchain: + if crate_info.type in ["dylib", "cdylib"]: + runtime_libs = cc_toolchain.dynamic_runtime_lib(feature_configuration = feature_configuration) + else: + runtime_libs = cc_toolchain.static_runtime_lib(feature_configuration = feature_configuration) + for lib in runtime_libs.to_list(): + static_runtime_libs.append(lib) + arg = get_lib_name(lib) + if not for_windows: + arg = "-l" + arg + if type(rustdoc_flags) == "Args": + rustdoc_flags.add("-Clink-arg=%s" % arg) + else: + rustdoc_flags.append("-Clink-arg=%s" % arg) + args, env = construct_arguments( ctx = ctx, attr = ctx.attr, @@ -214,6 +245,7 @@ def rustdoc_compile_action( arguments = args.all, supports_path_mapping = args.supports_path_mapping, tools = [toolchain.rust_doc], + static_runtime_libs = static_runtime_libs, ) def _zip_action(ctx, input_dir, output_zip, crate_label): diff --git a/rust/private/rustdoc_test.bzl b/rust/private/rustdoc_test.bzl index 733444eff7..664878cecd 100644 --- a/rust/private/rustdoc_test.bzl +++ b/rust/private/rustdoc_test.bzl @@ -89,6 +89,21 @@ def _construct_writer_arguments(ctx, test_runner, opt_test_params, action, crate if dep_cc_info: _collect_library_roots(roots, dep_cc_info.linking_context.linker_inputs) + # The cc_toolchain runtime libs (see rustdoc.bzl) are built in their own + # configuration, so their root differs from every crate root collected + # above. Without stripping it too, the `-Lnative=` search path rustc emits + # for them stays an execroot path that does not exist under runfiles, and + # the linker reports "unable to find library". + # + # Source files have an empty root, and they need no stripping: their + # `-Lnative=` path is already workspace-relative. Skip them -- an empty + # root would add `--strip_substring=/`, and the writer applies these as + # plain string replacements, so that would delete every `/` in every + # argument. + for lib in action.static_runtime_libs: + if lib.root.path: + roots.append(lib.root.path) + writer_args.add_all(roots, format_each = "--strip_substring=%s/", uniquify = True) # Indicate that the rustdoc_test args are over. From 42688d90e3e7beef081de58d86c620c84f4f19a3 Mon Sep 17 00:00:00 2001 From: Marvin Hansen Date: Tue, 11 Aug 2026 15:00:29 +0800 Subject: [PATCH 3/4] fix(rustdoc): link doc tests against the cc_toolchain's runtime libs Fixes the reproduction added in the previous commit: `//test/unit/cc_toolchain_runtime_lib:runtime_libs_test/doc_test` now passes. Two parts, both required. 1. `rustdoc.bzl` extends the #4080 `-Clink-arg=-l` loop to the cc_toolchain runtime libs, mirroring the crate-type split in `collect_inputs` so the libs it names are the ones that were added to the action inputs. Guarded on `cc_toolchain` being present, since #3665 made it optional. The libs are returned in the action struct for use by (2). 2. `rustdoc_test.bzl` adds those libs' root to the `--strip_substring` list. Without this the `-l` alone still fails with "unable to find library": the runtime libs are built in a different configuration than the crate outputs, so their root is not among the crate roots already collected, and the `-Lnative=` path stays an execroot path that does not exist in the runfiles tree the doc test runs from. Roots are only added when non-empty. Source-file runtime libs have an empty root and need no stripping -- their `-Lnative=` path is already workspace-relative -- and an empty root would emit `--strip_substring=/`. `rustdoc_test_writer` applies these as plain `str::replace`, so that would delete every `/` in every argument. The libs already reach the doc test's runfiles via `ctx.runfiles(transitive_files = action.inputs)`, thanks to #3741, so no additional wiring is needed there. Verified on macOS with `bazel test -- //... -//test/unit/remap_path_prefix:integration_test`: 569 tests pass and 35 are skipped, against 568/35 before this series. The only target that changed status is the new doc test. Every other action was a cache hit, so the change is a provable no-op for toolchains whose `static_runtime_lib` is empty. --- rust/private/rustdoc.bzl | 32 ++++++++++++++++++++++++++++++++ rust/private/rustdoc_test.bzl | 15 +++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/rust/private/rustdoc.bzl b/rust/private/rustdoc.bzl index 4c407755f9..cc0a23e5c7 100644 --- a/rust/private/rustdoc.bzl +++ b/rust/private/rustdoc.bzl @@ -144,6 +144,10 @@ def rustdoc_compile_action( # back to the crate root, which is always a `File` per the provider contract. rustdoc_crate_info = _rustdoc_crate_info(crate_info, output if output != None else crate_info.root) + # Runtime libs contributed by the cc_toolchain, tracked so `rust_doc_test` + # can strip their (separately configured) root from the runfiles paths. + static_runtime_libs = [] + # rustdoc does not understand linker flags like -lstatic that # `include_link_flags` generates. So we manually build flags that only apply # to rustdoc. @@ -170,6 +174,33 @@ def rustdoc_compile_action( else: rustdoc_flags.append("-Clink-arg=%s" % arg) + # The cc_toolchain's runtime libs (libc++ / libunwind on any toolchain + # enabling `static_link_cpp_runtimes`) are NOT part of + # transitive_noncrates: Bazel injects them into C++ link actions, and + # rustdoc never runs one -- it drives the link itself. + # `add_native_link_flags` emits their `-Lnative=` search path + # unconditionally but gates the matching `-lstatic=` behind + # `include_link_flags`, which is False for rustdoc. Without the `-l` + # below the archives sit on the search path with nothing referencing + # them, and every doc test fails to link with undefined `_Unwind_*`. + # + # Mirrors the crate-type split in `collect_inputs`, so the libs named + # here are the ones that were added to the action inputs. + if cc_toolchain: + if crate_info.type in ["dylib", "cdylib"]: + runtime_libs = cc_toolchain.dynamic_runtime_lib(feature_configuration = feature_configuration) + else: + runtime_libs = cc_toolchain.static_runtime_lib(feature_configuration = feature_configuration) + for lib in runtime_libs.to_list(): + static_runtime_libs.append(lib) + arg = get_lib_name(lib) + if not for_windows: + arg = "-l" + arg + if type(rustdoc_flags) == "Args": + rustdoc_flags.add("-Clink-arg=%s" % arg) + else: + rustdoc_flags.append("-Clink-arg=%s" % arg) + args, env = construct_arguments( ctx = ctx, attr = ctx.attr, @@ -214,6 +245,7 @@ def rustdoc_compile_action( arguments = args.all, supports_path_mapping = args.supports_path_mapping, tools = [toolchain.rust_doc], + static_runtime_libs = static_runtime_libs, ) def _zip_action(ctx, input_dir, output_zip, crate_label): diff --git a/rust/private/rustdoc_test.bzl b/rust/private/rustdoc_test.bzl index 733444eff7..664878cecd 100644 --- a/rust/private/rustdoc_test.bzl +++ b/rust/private/rustdoc_test.bzl @@ -89,6 +89,21 @@ def _construct_writer_arguments(ctx, test_runner, opt_test_params, action, crate if dep_cc_info: _collect_library_roots(roots, dep_cc_info.linking_context.linker_inputs) + # The cc_toolchain runtime libs (see rustdoc.bzl) are built in their own + # configuration, so their root differs from every crate root collected + # above. Without stripping it too, the `-Lnative=` search path rustc emits + # for them stays an execroot path that does not exist under runfiles, and + # the linker reports "unable to find library". + # + # Source files have an empty root, and they need no stripping: their + # `-Lnative=` path is already workspace-relative. Skip them -- an empty + # root would add `--strip_substring=/`, and the writer applies these as + # plain string replacements, so that would delete every `/` in every + # argument. + for lib in action.static_runtime_libs: + if lib.root.path: + roots.append(lib.root.path) + writer_args.add_all(roots, format_each = "--strip_substring=%s/", uniquify = True) # Indicate that the rustdoc_test args are over. From 47a9fd98b40798e32183dc7d9051d5d9f35dca75 Mon Sep 17 00:00:00 2001 From: Marvin Hansen Date: Tue, 11 Aug 2026 15:48:17 +0800 Subject: [PATCH 4/4] test(rustdoc): accept the msvc spelling of the runtime lib link arg The doc test assertion added alongside the runtime-lib fix hardcoded `-Clink-arg=-ldummy`, which fails on Windows CI: error: expected '-Clink-arg=-ldummy' in the rustdoc test link args: '[..., "-Clink-arg=dummy"]' The fix itself works there -- `rustdoc.bzl` omits the `-l` prefix when the target ABI is msvc, where link.exe takes bare library names, matching the existing #4080 loop. Only the test was wrong. Signed-off-by: Marvin Hansen --- .../cc_toolchain_runtime_lib_test.bzl | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/test/unit/cc_toolchain_runtime_lib/cc_toolchain_runtime_lib_test.bzl b/test/unit/cc_toolchain_runtime_lib/cc_toolchain_runtime_lib_test.bzl index 8ccc98fa7e..504cb106a2 100644 --- a/test/unit/cc_toolchain_runtime_lib/cc_toolchain_runtime_lib_test.bzl +++ b/test/unit/cc_toolchain_runtime_lib/cc_toolchain_runtime_lib_test.bzl @@ -101,15 +101,17 @@ def _rustdoc_link_args_analysis_test_impl(ctx): ) if action: - for expected in ctx.attr.expected_args: - asserts.true( - env, - expected in action.argv, - "error: expected '{}' in the rustdoc test link args: '{}'".format( - expected, - action.argv, - ), - ) + # Any one of the accepted spellings is enough: `rustdoc.bzl` omits the + # `-l` prefix when the target ABI is msvc, where link.exe takes bare + # library names. + asserts.true( + env, + any([expected in action.argv for expected in ctx.attr.expected_any_of]), + "error: expected one of {} in the rustdoc test link args: '{}'".format( + ctx.attr.expected_any_of, + action.argv, + ), + ) return analysistest.end(env) @@ -124,7 +126,7 @@ rustdoc_link_args_analysis_test = analysistest.make( fails to link against a toolchain that supplies its own unwinder. """, attrs = { - "expected_args": attr.string_list(), + "expected_any_of": attr.string_list(), }, ) @@ -199,7 +201,8 @@ def runtime_libs_test(name): ) # A doc test links like a binary, so it needs the static runtime lib named - # on the command line. `dummy.a` yields `-ldummy` via `get_lib_name`. + # on the command line. `dummy.a` yields `dummy` via `get_lib_name`, spelled + # `-ldummy` everywhere except msvc. rust_library( name = "%s/__doctest_library" % name, edition = "2018", @@ -225,5 +228,9 @@ def runtime_libs_test(name): rustdoc_link_args_analysis_test( name = "%s/doc_test" % name, target_under_test = "%s/_doc_test" % name, - expected_args = ["-Clink-arg=-ldummy"], + expected_any_of = [ + "-Clink-arg=-ldummy", + # msvc: link.exe takes bare library names, no `-l`. + "-Clink-arg=dummy", + ], )