Skip to content

Commit 4c2c460

Browse files
authored
Tolerate partial build failures during rust-analyzer discovery (bazelbuild#4127)
`discover_bazel_rust_project` builds the rust-analyzer aspect over the whole workspace and bailed on any non-zero Bazel exit. In a large monorepo a single broken target (e.g. an unrelated, unmaintained one) aborted the build, leaving rust-analyzer with no Bazel project model. It then silently falls back to a Cargo workspace, which breaks proc-macro expansion (toolchain ABI mismatch) and materializes a stray `target/`. Pass `--keep_going` so Bazel keeps building the rest of the graph, and treat the result as usable when the BEP still yielded crate specs: proceed with a `log::warn` that the project may be incomplete, and only fail when zero specs were produced. This mirrors `flycheck`'s existing exit-code handling. Add an end-to-end test that stands up a workspace with one good `rust_library` and one target that fails analysis, then checks discovery still returns a project containing the good crate. Unit tests cover the `assess_discovery` decision. Written with Claude, but I believe I understand what is going on.
1 parent 7fb3e6d commit 4c2c460

4 files changed

Lines changed: 159 additions & 10 deletions

File tree

.bazelci/presubmit.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ tasks:
399399
- "//tools/rust_analyzer:gen_rust_project"
400400
- "//tools/rust_analyzer:discover_bazel_rust_project"
401401
- "//test/rust_analyzer:rust_analyzer_test"
402+
- "//test/rust_analyzer:partial_failure_test"
402403
ide_integration_tests_macos:
403404
name: IDE VSCode Tests
404405
platform: macos_arm64
@@ -408,6 +409,7 @@ tasks:
408409
- "//tools/rust_analyzer:gen_rust_project"
409410
- "//tools/rust_analyzer:discover_bazel_rust_project"
410411
- "//test/rust_analyzer:rust_analyzer_test"
412+
- "//test/rust_analyzer:partial_failure_test"
411413
ide_integration_tests_windows:
412414
name: IDE VSCode Tests
413415
platform: windows

test/rust_analyzer/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ sh_binary(
55
srcs = ["rust_analyzer_test_runner.sh"],
66
args = [package_name()],
77
)
8+
9+
sh_binary(
10+
name = "partial_failure_test",
11+
srcs = ["partial_failure_test_runner.sh"],
12+
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
3+
# End-to-end test that discovery still produces a project when part of
4+
# the workspace is broken. Stands up a workspace with one good
5+
# `rust_library` and one target that fails analysis, runs
6+
# `discover_bazel_rust_project`, and checks the good crate is still
7+
# discovered. See `--keep_going` in tools/rust_analyzer/lib.rs.
8+
9+
set -euo pipefail
10+
11+
if [[ -z "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then
12+
>&2 echo "This script should be run under Bazel"
13+
exit 1
14+
fi
15+
16+
workspace="$(mktemp -d -t rules_rust_ra_partial-XXXXXXXXXX)"
17+
18+
cat >"${workspace}/MODULE.bazel" <<EOF
19+
module(name = "rules_rust_ra_partial", version = "0.0.0")
20+
bazel_dep(name = "rules_rust", version = "0.0.0")
21+
local_path_override(
22+
module_name = "rules_rust",
23+
path = "${BUILD_WORKSPACE_DIRECTORY}",
24+
)
25+
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
26+
use_repo(rust, "rust_toolchains")
27+
register_toolchains("@rust_toolchains//:all")
28+
EOF
29+
30+
if [[ -f "${BUILD_WORKSPACE_DIRECTORY}/.bazelversion" ]]; then
31+
cp "${BUILD_WORKSPACE_DIRECTORY}/.bazelversion" "${workspace}/.bazelversion"
32+
fi
33+
34+
echo "pub fn good() {}" >"${workspace}/lib.rs"
35+
36+
cat >"${workspace}/BUILD.bazel" <<EOF
37+
load("@rules_rust//rust:defs.bzl", "rust_library")
38+
39+
rust_library(
40+
name = "good",
41+
srcs = ["lib.rs"],
42+
edition = "2021",
43+
)
44+
45+
# Fails analysis: depends on a target that does not exist.
46+
rust_library(
47+
name = "bad",
48+
srcs = ["lib.rs"],
49+
edition = "2021",
50+
deps = [":does_not_exist"],
51+
)
52+
EOF
53+
54+
cd "${workspace}"
55+
56+
fail() {
57+
>&2 echo "FAIL: $1"
58+
>&2 echo "--- discovery.json ---"
59+
>&2 cat discovery.json
60+
exit 1
61+
}
62+
63+
# The workspace must actually fail to build, or the checks below are
64+
# vacuous.
65+
if bazel build //... >/dev/null 2>&1; then
66+
>&2 echo "FAIL: expected '//...' to fail to build"
67+
exit 1
68+
fi
69+
70+
echo "Running discovery against the half-broken workspace..."
71+
bazel run @rules_rust//tools/rust_analyzer:discover_bazel_rust_project >discovery.json || true
72+
73+
grep -q '"kind":"error"' discovery.json && fail "discovery returned an error instead of a partial project"
74+
grep -q '"kind":"finished"' discovery.json || fail "discovery did not finish"
75+
grep -q '"display_name":"good"' discovery.json || fail "the good crate is missing from the project"
76+
grep -q '"display_name":"bad"' discovery.json && fail "the bad crate should have been skipped but is present"
77+
78+
echo "PASS: discovery produced a project with 'good' and without 'bad'"
79+
80+
bazel clean --expunge --async >/dev/null 2>&1 || true
81+
cd /
82+
rm -rf "${workspace}"

tools/rust_analyzer/lib.rs

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ pub fn generate_rust_project(
7676
let bep_file = output_base.join(format!("rules_rust_ra_bep_{}.json", std::process::id()));
7777
let _bep_cleanup = BepCleanup(bep_file.clone());
7878

79-
generate_crate_info(
79+
let build = generate_crate_info(
8080
bazel,
8181
output_base,
8282
workspace,
@@ -87,8 +87,24 @@ pub fn generate_rust_project(
8787
&bep_file,
8888
)?;
8989

90-
let spec_paths =
91-
bep::parse_spec_paths(&bep_file).with_context(|| format!("parsing BEP file {bep_file}"))?;
90+
let spec_paths = match bep::parse_spec_paths(&bep_file) {
91+
Ok(paths) => paths,
92+
// A failed build often means a missing or partial BEP file; surface
93+
// the build error rather than the downstream parse error.
94+
Err(_) if !build.success => {
95+
bail!(
96+
"bazel build failed and produced no usable output:\n{}",
97+
build.stderr
98+
)
99+
}
100+
Err(e) => return Err(e).with_context(|| format!("parsing BEP file {bep_file}")),
101+
};
102+
if assess_discovery(build.success, spec_paths.len(), &build.stderr)? {
103+
log::warn!(
104+
"some targets failed to build; the rust-analyzer project may be \
105+
incomplete. Run `bazel build //...` to see the errors."
106+
);
107+
}
92108
log::info!("discovered {} crate spec files via BEP", spec_paths.len());
93109

94110
// Toolchain-info JSON is embedded at compile time — see
@@ -244,6 +260,13 @@ pub fn bazel_info(
244260
Ok(info_map)
245261
}
246262

263+
/// Result of the discovery build. With `--keep_going` a non-`success` exit
264+
/// isn't fatal — the caller proceeds if the BEP still yielded specs.
265+
struct DiscoveryBuild {
266+
success: bool,
267+
stderr: String,
268+
}
269+
247270
#[allow(clippy::too_many_arguments)]
248271
fn generate_crate_info(
249272
bazel: &Utf8Path,
@@ -254,14 +277,17 @@ fn generate_crate_info(
254277
rules_rust: &str,
255278
targets: &[String],
256279
bep_file: &Utf8Path,
257-
) -> anyhow::Result<()> {
280+
) -> anyhow::Result<DiscoveryBuild> {
258281
log::info!("running bazel build with BEP discovery...");
259282
log::debug!("Building rust_analyzer_crate_spec files for {:?}", targets);
260283

261284
let output = bazel_command(bazel, Some(workspace), Some(output_base))
262285
.args(bazel_startup_options)
263286
.arg("build")
264287
.args(bazel_args)
288+
// Don't let one broken target abort discovery for the whole
289+
// workspace; the caller decides if the partial result is usable.
290+
.arg("--keep_going")
265291
.arg("--norun_validations")
266292
.arg("--remote_download_all")
267293
.arg(format!(
@@ -275,15 +301,25 @@ fn generate_crate_info(
275301
.args(targets)
276302
.output()?;
277303

278-
if !output.status.success() {
279-
let status = output.status;
280-
let stderr = String::from_utf8_lossy(&output.stderr);
281-
bail!("bazel build failed: ({status})\n{stderr}");
304+
let success = output.status.success();
305+
if success {
306+
log::info!("bazel build finished");
282307
}
283308

284-
log::info!("bazel build finished");
309+
Ok(DiscoveryBuild {
310+
success,
311+
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
312+
})
313+
}
285314

286-
Ok(())
315+
/// `Ok(true)` = usable but some targets failed (caller warns); `Ok(false)` =
316+
/// clean; `Err` = nothing usable was produced.
317+
fn assess_discovery(success: bool, spec_count: usize, stderr: &str) -> anyhow::Result<bool> {
318+
match (success, spec_count) {
319+
(true, _) => Ok(false),
320+
(false, 0) => bail!("bazel build failed and produced no crate specs:\n{stderr}"),
321+
(false, _) => Ok(true),
322+
}
287323
}
288324

289325
fn bazel_command(
@@ -390,4 +426,28 @@ mod tests {
390426
// Mixed (defense in depth).
391427
assert_eq!(dir_to_bazel_package(r"a/b\c/d"), "a/b/c/d");
392428
}
429+
430+
#[test]
431+
fn assess_discovery_clean_build_is_complete() {
432+
// Success → never incomplete, regardless of spec count.
433+
assert_eq!(assess_discovery(true, 0, "").unwrap(), false);
434+
assert_eq!(assess_discovery(true, 42, "").unwrap(), false);
435+
}
436+
437+
#[test]
438+
fn assess_discovery_partial_failure_is_usable_but_incomplete() {
439+
// Some targets failed but specs were produced (e.g. an unrelated
440+
// broken target in a large monorepo) → proceed, but flag incomplete.
441+
assert_eq!(assess_discovery(false, 2712, "boom").unwrap(), true);
442+
}
443+
444+
#[test]
445+
fn assess_discovery_total_failure_errors() {
446+
// Failed build with nothing usable → fatal, and the message carries
447+
// the captured stderr so the user sees the real cause.
448+
let err = assess_discovery(false, 0, "the real bazel error")
449+
.unwrap_err()
450+
.to_string();
451+
assert!(err.contains("the real bazel error"), "got: {err}");
452+
}
393453
}

0 commit comments

Comments
 (0)