-
Notifications
You must be signed in to change notification settings - Fork 13.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
doctests: fix merging on stable #137899
doctests: fix merging on stable #137899
Conversation
Good catch! r=me once CI pass. Also is there a way to test it? |
src/librustdoc/doctest.rs
Outdated
// The merged test harness uses the `test` crate, so we need to actually allow it. | ||
// This will not expose nightly features on stable, because crate attrs disable | ||
// merging, and `#![feature]` is required to be a crate attr. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm afraid that isn't true in practice because partition_source
is pretty naive and buggy at the moment as it only detects crate attributes that literally start with the string #![
! Literally adding a space between one of these tokens succeeds in circumventing partition_source
's checks.
I presume code like the following would start compiling on stable once this PR hits stable (edition 2024):
//! ```
//! #! [feature(rustc_attrs)]
//! #! [rustc_coherence_is_core]
//! impl i32 { fn identity(self) -> Self { self } }
//! fn main() { assert_eq!(1 + 1, 2i32.identity()); }
//! ```
We should address that bug really really soon, ideally blocking this PR??
The crate attribute checks should not be textual but syntactic (to account for things like #/**/!/**/[…]
, too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess I can be convinced that this needn't block this PR because this is obviously a crazy corner case / "exploit" nobody would think about immediately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you trick rustdoc into letting you use a feature attribute in a merged doctest, it still won't work, because merged doctests are inside modules, and feature flags aren't allowed in modules.
warning: crate-level attribute should be in the root module
--> src/lib.rs:7:5
|
5 | #! [feature(rustc_attrs)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_attributes)]` on by default
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I'm aware of that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason it still works for rustc_attrs
and test
specifically is because the feature
is also present in the crate root (and the non-crate-root attribute is just a warning):
#![allow(unused_extern_crates)]
#![allow(internal_features)]
#![feature(test)]
#![feature(rustc_attrs)]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See the generated crate (notice #![feature(rustc_attrs)]
in the crate root + the #! [feature(rustc_attrs)]
in mod __doctest_0
):
#![allow(unused_extern_crates)]
#![allow(internal_features)]
#![feature(test)]
#![feature(rustc_attrs)]
#![allow(unused)]
extern crate test;
mod __doctest_0 {
#! [feature(rustc_attrs)]
#! [rustc_coherence_is_core]
impl i32 { fn identity(self) -> Self { self } }
fn main() { assert_eq!(1 + 1, 2i32.identity()); }
pub const TEST: test::TestDescAndFn = test::TestDescAndFn::new_doctest(
"again.rs - (line 1)", false, "again.rs", 1, false, false,
test::StaticTestFn(
|| {
if let Some(bin_path) = crate::__doctest_mod::doctest_path() {
test::assert_test_result(crate::__doctest_mod::doctest_runner(bin_path, 0))
} else {
test::assert_test_result(self::main())
}
},
));
}
mod __doctest_mod {
use std::sync::OnceLock;
use std::path::PathBuf;
pub static BINARY_PATH: OnceLock<PathBuf> = OnceLock::new();
pub const RUN_OPTION: &str = "RUSTDOC_DOCTEST_RUN_NB_TEST";
#[allow(unused)]
pub fn doctest_path() -> Option<&'static PathBuf> {
self::BINARY_PATH.get()
}
#[allow(unused)]
pub fn doctest_runner(bin: &std::path::Path, test_nb: usize) -> Result<(), String> {
let out = std::process::Command::new(bin)
.env(self::RUN_OPTION, test_nb.to_string())
.args(std::env::args().skip(1).collect::<Vec<_>>())
.output()
.expect("failed to run command");
if !out.status.success() {
Err(String::from_utf8_lossy(&out.stderr).to_string())
} else {
Ok(())
}
}
}
#[rustc_main]
fn main() -> std::process::ExitCode {
const TESTS: [test::TestDescAndFn; 1] = [__doctest_0::TEST];
let test_marker = std::ffi::OsStr::new(__doctest_mod::RUN_OPTION);
let test_args = &["rustdoctest".to_string(),];
const ENV_BIN: &'static str = "RUSTDOC_DOCTEST_BIN_PATH";
if let Ok(binary) = std::env::var(ENV_BIN) {
let _ = crate::__doctest_mod::BINARY_PATH.set(binary.into());
unsafe { std::env::remove_var(ENV_BIN); }
return std::process::Termination::report(test::test_main(test_args, Vec::from(TESTS), None));
} else if let Ok(nb_test) = std::env::var(__doctest_mod::RUN_OPTION) {
if let Ok(nb_test) = nb_test.parse::<usize>() {
if let Some(test) = TESTS.get(nb_test) {
if let test::StaticTestFn(f) = test.testfn {
return std::process::Termination::report(f());
}
}
}
panic!("Unexpected value for `{}`", __doctest_mod::RUN_OPTION);
}
eprintln!("WARNING: No rustdoc doctest environment variable provided so doctests will be run in the same process");
std::process::Termination::report(test::test_main(test_args, Vec::from(TESTS), None))
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@fmease Yes, that's a problem.
I've pushed a second commit that should solve this problem by building the test bundle and the test harness as a stable library crate that exports all the main functions, plus a harness with unstable features that runs them). This should make it impossible for a user to accidentally use a nightly-only feature.
I've also added a test case to ensure this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason it still works for rustc_attrs and test specifically
Hmmm, I've now done local testing of the original version of this PR and it's possible that I am partially mistaken, it's all so confusing. The example from #137899 (comment) might not compile after all on stable even with RUSTC_BOOTSTRAP=1
? I don't really know. I've tried RUSTC_BOOTSTRAP=-1 rustdoc +FIRST_COMMIT_ONLY file.rs --edition 2024 --test
(-1
makes rustc/rustdoc think it's stable) and that still leads to
error[E0554]: `#![feature]` may not be used on the nightly release channel
--> file.rs:2:1
|
2 | #! [feature(rustc_attrs)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^
However, I don't know if RUSTC_BOOTSTRAP=-1
is a proper replacement for "bootstrap profile == dist" (it that how actual stable distributions of rustc/rustdoc come to life?). I'm flabbergasted as to why that doesn't compile.
Still, the following "exploit" does compile (and I'm more than confused what's the difference to the previous code) under RUSTC_BOOTSTRAP=-1 rustdoc +FIRST_COMMIT_ONLY file.rs --edition 2024 --test
:
//! ```
//! #! [feature(rustc_attrs)]
//! #[rustc_coinductive] trait Trait {}
//! ```
Important
In any case, it seems like my original comment still holds in that the first commit is exploitable.
Another currently less severe example: Assuming --doctest-compilation-args
is stable (which it isn't yet), this would be a different category of exploit: RUSTC_BOOTSTRAP=-1 rustdoc +FIRST_COMMIT_ONLY file.rs --edition 2024 --test --doctest-compilation-args -Zfmt-debug=none
(where the RUSTC_BOOTSTRAP=-1
is just a placeholder for "stable distribution"):
//! ```
//! #[derive(Debug)] struct Pair;
//! assert!(format!("{Pair:?}").is_empty());
//! ```
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like #![rustc_coherence_is_core]
is silently ignored when not at the crate root. Which means, if I run run with --persist-doctests
, the resulting code gets the "cannot define inherent impl outside of core" error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know the specifics about this rustdoc issue, but:
However, I don't know if
RUSTC_BOOTSTRAP=-1
is a proper replacement for "bootstrap profile == dist" (it that how actual stable distributions of rustc/rustdoc come to life?).
It is not for the same purpose and is not a substitute for bootstrap configuration. The dist profile of bootstrap sets additional things. RUSTC_BOOTSTRAP=-1
is an internal-testing-only flag (I added it only recently in #132993 mostly to test rustc nightly-gated diagnostics differences) to trick rustc into thinking it is a stable-channel compiler and is not a substitute for dist config.
Bootstrap itself does not use RUSTC_BOOTSTRAP=-1
for producing dist versions of rustc/rustdoc, and instead proper channel info is used.
For the intentions of this PR, I think it's best forgotten that RUSTC_BOOTSTRAP=-1
exists.
@rustbot review |
This PR modifies cc @jieyouxu |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've gone through all files and tested things locally. This is definitely the approach we should take. Very good idea, thanks!
The code constructing the compiler invocations is messier than I'd hoped (out of necessity!). There's definitely room for cleanups and refactoring which can be done in follow-up PRs. I must say — only having gone through the changes once — I'm not 100% confident yet I didn't miss potential issues with the way flags are passed since it's all very delicate (cf. the string-based extraction of transitive deps from --extern
(don't we need to "import" adjust transitives -L
s too?)).
I might want to go over it again but generally +1 from me. (The last 4 commits should be squashed into one.)
We likely want to nominate this for stable backport too and force a point release. Backports should generally be small and low risk (since it obviously prevents real users from testing the changes "outside of production") which this PR isn't I'd say. So we might want to think about alternative strategies (like only shipping the first commit for a release???).
fn path_for_merged_doctest_runner(&self) -> PathBuf { | ||
self.test_opts.outdir.path().join(format!("doctest_runner_{}.rs", self.edition)) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(definitely not blocking) With some work it might be possible to generate a single doctest_runner.rs
for all editions unless I'm missing something glaringly obvious
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Theoretically, yes, but that would require a bunch of refactoring to generate the runner, and doesn't seem worth it for a niche feature like writing doctests with a different edition than the crate.
2afe69c
to
c67b75a
Compare
cf175d7
to
6e3e206
Compare
We need to generate -Ls for direct deps because the direct deps of the test bundle become transitive deps of the runner. |
☔ The latest upstream changes (presumably #138155) made this pull request unmergeable. Please resolve the merge conflicts. |
for extern_str in &rustdoc_options.extern_strs { | ||
if let Some((_cratename, path)) = extern_str.split_once('=') { | ||
// Direct dependencies of the tests themselves are | ||
// indirect dependencies of the test runner. | ||
// They need to be in the library search path. | ||
let dir = Path::new(path) | ||
.parent() | ||
.filter(|x| x.components().count() > 0) | ||
.unwrap_or(Path::new(".")); | ||
runner_compiler.arg("-L").arg(dir); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering, is this actually necessary? Did you have a concrete case where this was needed?
Locally I've experimented with the consequences of removing it, couldn't find any problems and all tests pass without it, too. It's possible that I missed something.
Everything seems to work out just fine without this extra logic because any --extern
s and -L
s (most likely -Ldependency=
s from Cargo) passed to rustdoc for the "overarching host crate" (the crate containing the doctest) get stuffed into the args file rustdoc-cfgs
which we use for compiling both the bundle and the runner.
And if the (non-Cargo) user / Cargo itself were to pass too few -L
flags to the host crate, then the latter wouldn't even compile and we wouldn't even reach this function run_test
. Example:
directory/foreign.rs
(rustc directory/foreign.rs --crate-type=lib --out-dir directory/
):
pub const VALUE: bool = true;
file.rs
:
//! ```
//! assert!(foreign::VALUE);
//! ```
rustdoc file.rs --edition 2024 --test --extern foreign=directory/libforeign.rlib
passes just fine without this loop overextern_strs
because the argsfile (shared by the bundle & the runner compiler) contains--extern foreign=directory/libforeign.rlib
. This loop would've just added-Ldirectory
which is unnecessary.- (
rustdoc file.rs --edition 2024 --test --extern foreign -Ldependency
also passes because the argsfile contains-Ldirectory
and--extern=foreign
but ofc that isn't relevant to this loop as it skips such externs) - if you only want to add
foreign
to the doctest, not the host crate:rustdoc file.rs --edition 2024 --test -Zunstable-options --doctest-compilation-args '--extern foreign=directory/libforeign.rlib'
: This also passes just fine because of the argsfile and it obviously doesn't trigger this loop at all
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similarly for more complex crate graphs (a.rs
<- b.rs
<- c.rs
(with doctest)):
rustc directory/a.rs --crate-type=lib --out-dir directory
rustc directory/b.rs --crate-type=lib --edition 2018 --extern a -Ldirectory
(or similar)- finally either of
rustdoc c.rs --edition 2024 --test --extern b=directory/libb.rlib -Ldependency=directory
also passes without this loop which would've added-Ldirectory
- (similarly,
rustdoc c.rs --edition 2024 --test -Zunstable-options --doctest-compilation-args '--extern foreign=separate/libforeign.rlib -Ldependency=separate'
)
I don't think this is necessary as written up in #137899 (comment). However, I won't block this PR on my concern (which could also be unjustified). I'm also a bit wary of generating extra |
Fixes rust-lang#137898 The generated multi-test harness relies on nightly-only APIs, so the only way to run it on stable is to enable them. Since tests that use crate attrs don't be merged, there's no way to use nightly-only features on it anyway.
This prevents the included test case from getting at nightly-only features when run on stable. The harness builds with RUSTC_BOOTSTRAP, but the bundle doesn't.
6e3e206
to
9cf531d
Compare
I've rebased and fixed a small typo ( |
@bors r=fmease,GuillaumeGomez rollup=always p=6 |
Beta and stable backport accepted as per Zulip polls: In both cases, out of 8 (7 active) members, 4 members voted for, 0 against, 1 abstained. |
☀️ Test successful - checks-actions |
A job failed! Check out the build log: (web) (plain) Click to see the possible cause of the failure (guessed by this bot)
|
Finished benchmarking commit (c8a5072): comparison URL. Overall result: ❌ regressions - no action needed@rustbot label: -perf-regression Instruction countThis is the most reliable metric that we have; it was used to determine the overall result at the top of this comment. However, even this metric can sometimes exhibit noise.
Max RSS (memory usage)This benchmark run did not return any relevant results for this metric. CyclesThis benchmark run did not return any relevant results for this metric. Binary sizeThis benchmark run did not return any relevant results for this metric. Bootstrap: 779.777s -> 778.705s (-0.14%) |
Fixes #137898
The generated multi-test harness relies on nightly-only APIs, so the only way to run it on stable is to enable them.
To prevent the executing test case from getting at any of the stuff that the harness uses, they're built as two separate crates. The test bundle isn't built with RUSTC_BOOTSTRAP, while the runner harness is.