Skip to content
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

Make cargo-miri run documentation tests #1671

Closed
wants to merge 11 commits into from
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ different Miri binaries, and as such worth documenting:
that the compiled `rlib`s are compatible with Miri.
When set while running `cargo-miri`, it indicates that we are part of a sysroot
build (for which some crates need special treatment).
* `MIRI_CALLED_FROM_RUSTDOC` when set to any value tells `cargo-miri` that it is
running as a child process of `rustdoc`, which invokes it twice for each doc-test
and requires special treatment, most notably a check-only build before interpretation.
This is set by `cargo-miri` itself when running as a `rustdoc`-wrapper.
* `MIRI_CWD` when set to any value tells the Miri driver to change to the given
directory after loading all the source files, but before commencing
interpretation. This is useful if the interpreted program wants a different
Expand Down
222 changes: 192 additions & 30 deletions cargo-miri/bin.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use std::env;
use std::ffi::OsString;
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::iter::TakeWhile;
use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
use std::ops::Not;
use std::path::{Path, PathBuf};
use std::process::Command;
Expand Down Expand Up @@ -46,6 +46,8 @@ struct CrateRunEnv {
env: Vec<(OsString, OsString)>,
/// The current working directory.
current_dir: OsString,
/// The contents passed via standard input.
stdin: Vec<u8>,
}

/// The information Miri needs to run a crate. Stored as JSON when the crate is "compiled".
Expand All @@ -63,7 +65,13 @@ impl CrateRunInfo {
let args = args.collect();
let env = env::vars_os().collect();
let current_dir = env::current_dir().unwrap().into_os_string();
Self::RunWith(CrateRunEnv { args, env, current_dir })

let mut stdin = Vec::new();
if env::var_os("MIRI_CALLED_FROM_RUSTDOC").is_some() {
RalfJung marked this conversation as resolved.
Show resolved Hide resolved
std::io::stdin().lock().read_to_end(&mut stdin).expect("cannot read stdin");
}

Self::RunWith(CrateRunEnv { args, env, current_dir, stdin })
}

fn store(&self, filename: &Path) {
Expand Down Expand Up @@ -178,6 +186,22 @@ fn exec(mut cmd: Command) {
}
}

/// Execute the command and pipe `input` into its stdin.
/// If it fails, fail this process with the same exit code.
/// Otherwise, continue.
fn exec_with_pipe(mut cmd: Command, input: &[u8]) {
cmd.stdin(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("failed to spawn process");
{
let stdin = child.stdin.as_mut().expect("failed to open stdin");
stdin.write_all(input).expect("failed to write out test source");
}
let exit_status = child.wait().expect("failed to run command");
if exit_status.success().not() {
std::process::exit(exit_status.code().unwrap_or(-1))
}
}

fn xargo_version() -> Option<(u32, u32, u32)> {
let out = xargo_check().arg("--version").output().ok()?;
if !out.status.success() {
Expand Down Expand Up @@ -579,17 +603,22 @@ fn phase_cargo_rustc(args: env::Args) {
}

fn out_filename(prefix: &str, suffix: &str) -> PathBuf {
let mut path = PathBuf::from(get_arg_flag_value("--out-dir").unwrap());
path.push(format!(
"{}{}{}{}",
prefix,
get_arg_flag_value("--crate-name").unwrap(),
// This is technically a `-C` flag but the prefix seems unique enough...
// (and cargo passes this before the filename so it should be unique)
get_arg_flag_value("extra-filename").unwrap_or(String::new()),
suffix,
));
path
if let Some(out_dir) = get_arg_flag_value("--out-dir") {
let mut path = PathBuf::from(out_dir);
path.push(format!(
"{}{}{}{}",
prefix,
get_arg_flag_value("--crate-name").unwrap(),
// This is technically a `-C` flag but the prefix seems unique enough...
// (and cargo passes this before the filename so it should be unique)
get_arg_flag_value("extra-filename").unwrap_or(String::new()),
suffix,
));
path
} else {
let out_file = get_arg_flag_value("-o").unwrap();
PathBuf::from(out_file)
}
}

let verbose = std::env::var_os("MIRI_VERBOSE").is_some();
Expand All @@ -608,7 +637,7 @@ fn phase_cargo_rustc(args: env::Args) {
_ => {},
}

let store_json = |info: CrateRunInfo| {
let store_json = |info: &CrateRunInfo| {
let filename = out_filename("", "");
if verbose {
eprintln!("[cargo-miri rustc] writing run info to `{}`", filename.display());
Expand All @@ -627,15 +656,55 @@ fn phase_cargo_rustc(args: env::Args) {
// like we want them.
// Instead of compiling, we write JSON into the output file with all the relevant command-line flags
// and environment variables; this is used when cargo calls us again in the CARGO_TARGET_RUNNER phase.
store_json(CrateRunInfo::collect(args));
let info = CrateRunInfo::collect(args);
store_json(&info);

// Rustdoc expects us to exit with an error code if the test is marked as `compile_fail`,
// just creating the JSON file is not enough: we need to detect syntax errors,
// so we need to run Miri with `MIRI_BE_RUSTC` for a check-only build.
RalfJung marked this conversation as resolved.
Show resolved Hide resolved
if std::env::var_os("MIRI_CALLED_FROM_RUSTDOC").is_some() {
RalfJung marked this conversation as resolved.
Show resolved Hide resolved
let mut cmd = miri();
let env = if let CrateRunInfo::RunWith(env) = info {
env
} else {
return;
RalfJung marked this conversation as resolved.
Show resolved Hide resolved
};

// use our own sysroot
if !has_arg_flag("--sysroot") {
let sysroot = env::var_os("MIRI_SYSROOT")
.expect("the wrapper should have set MIRI_SYSROOT");
cmd.arg("--sysroot").arg(sysroot);
}
RalfJung marked this conversation as resolved.
Show resolved Hide resolved

// ensure --emit argument for a check-only build is present
RalfJung marked this conversation as resolved.
Show resolved Hide resolved
if let Some(i) = env.args.iter().position(|arg| arg.starts_with("--emit=")) {
// We need to make sure we're not producing a binary that overwrites the JSON file.
// rustdoc should only ever pass an --emit=metadata argument for tests marked as `no_run`:
assert_eq!(env.args[i], "--emit=metadata");
} else {
cmd.arg("--emit=dep-info,metadata");
}

cmd.args(env.args);
cmd.env("MIRI_BE_RUSTC", "1");

if verbose {
eprintln!("[cargo-miri rustc] captured input:\n{}", std::str::from_utf8(&env.stdin).unwrap());
eprintln!("[cargo-miri rustc] {:?}", cmd);
}

exec_with_pipe(cmd, &env.stdin);
}

return;
}

if runnable_crate && ArgFlagValueIter::new("--extern").any(|krate| krate == "proc_macro") {
// This is a "runnable" `proc-macro` crate (unit tests). We do not support
// interpreting that under Miri now, so we write a JSON file to (display a
// helpful message and) skip it in the runner phase.
store_json(CrateRunInfo::SkipProcMacroTest);
store_json(&CrateRunInfo::SkipProcMacroTest);
return;
}

Expand Down Expand Up @@ -697,6 +766,18 @@ fn phase_cargo_rustc(args: env::Args) {
}
}

fn forward_patched_extern_arg(args: &mut impl Iterator<Item = String>, cmd: &mut Command) {
cmd.arg("--extern"); // always forward flag, but adjust filename:
let path = args.next().expect("`--extern` should be followed by a filename");
if let Some(lib) = path.strip_suffix(".rlib") {
// If this is an rlib, make it an rmeta.
cmd.arg(format!("{}.rmeta", lib));
} else {
// Some other extern file (e.g. a `.so`). Forward unchanged.
cmd.arg(path);
}
}

fn phase_cargo_runner(binary: &Path, binary_args: env::Args) {
let verbose = std::env::var_os("MIRI_VERBOSE").is_some();

Expand Down Expand Up @@ -742,16 +823,7 @@ fn phase_cargo_runner(binary: &Path, binary_args: env::Args) {
let json_flag = "--json";
while let Some(arg) = args.next() {
if arg == extern_flag {
cmd.arg(extern_flag); // always forward flag, but adjust filename
// `--extern` is always passed as a separate argument by cargo.
let next_arg = args.next().expect("`--extern` should be followed by a filename");
if let Some(next_lib) = next_arg.strip_suffix(".rlib") {
// If this is an rlib, make it an rmeta.
cmd.arg(format!("{}.rmeta", next_lib));
} else {
// Some other extern file (e.g., a `.so`). Forward unchanged.
cmd.arg(next_arg);
}
forward_patched_extern_arg(&mut args, &mut cmd);
} else if arg.starts_with(error_format_flag) {
let suffix = &arg[error_format_flag.len()..];
assert!(suffix.starts_with('='));
Expand Down Expand Up @@ -793,6 +865,73 @@ fn phase_cargo_runner(binary: &Path, binary_args: env::Args) {
if verbose {
eprintln!("[cargo-miri runner] {:?}", cmd);
}

if std::env::var_os("MIRI_CALLED_FROM_RUSTDOC").is_some() {
exec_with_pipe(cmd, &info.stdin)
} else {
exec(cmd)
}
}

fn phase_cargo_rustdoc(fst_arg: &str, mut args: env::Args) {
let verbose = std::env::var_os("MIRI_VERBOSE").is_some();

// phase_cargo_miri sets the RUSTDOC env var to ourselves, so we can't use that here;
// just default to a straight-forward invocation for now:
let mut cmd = Command::new(OsString::from("rustdoc"));
RalfJung marked this conversation as resolved.
Show resolved Hide resolved

// Because of the way the main function is structured, we have to take the first argument spearately
// from the rest; to simplify the following argument patching loop, we'll just skip that one.
// This is fine for now, because cargo will never pass --extern arguments in the first position,
// but we should defensively assert that this will work.
let extern_flag = "--extern";
assert!(fst_arg != extern_flag);
RalfJung marked this conversation as resolved.
Show resolved Hide resolved
cmd.arg(fst_arg);

let runtool_flag = "--runtool";
let mut crossmode = fst_arg == runtool_flag;
RalfJung marked this conversation as resolved.
Show resolved Hide resolved
while let Some(arg) = args.next() {
if arg == extern_flag {
// Patch --extern arguments to use *.rmeta files, since phase_cargo_rustc only creates stub *.rlib files.
forward_patched_extern_arg(&mut args, &mut cmd);
} else if arg == runtool_flag {
// An existing --runtool flag indicates cargo is running in cross-target mode, which we don't support.
// Note that this is only passed when cargo is run with the unstable -Zdoctest-xcompile flag;
// otherwise, we won't be called as rustdoc at all.
crossmode = true;
break;
} else {
cmd.arg(arg);
}
}

if crossmode {
eprintln!("Cross-interpreting doc-tests is not currently supported by Miri.");
return;
}

// For each doc-test, rustdoc starts two child processes: first the test is compiled,
// then the produced executable is invoked. We want to reroute both of these to cargo-miri,
// such that the first time we'll enter phase_cargo_rustc, and phase_cargo_runner second.
//
// rustdoc invokes the test-builder by forwarding most of its own arguments, which makes
// it difficult to determine when phase_cargo_rustc should run instead of phase_cargo_rustdoc.
// Furthermore, the test code is passed via stdin, rather than a temporary file, so we need
// to let phase_cargo_rustc know to expect that. We'll use this environment variable as a flag:
cmd.env("MIRI_CALLED_FROM_RUSTDOC", "1");

// The `--test-builder` and `--runtool` arguments are unstable rustdoc features,
// which are disabled by default. We first need to enable them explicitly:
cmd.arg("-Z").arg("unstable-options");
RalfJung marked this conversation as resolved.
Show resolved Hide resolved

let cargo_miri_path = std::env::current_exe().expect("current executable path invalid");
cmd.arg("--test-builder").arg(&cargo_miri_path); // invoked by forwarding most arguments
cmd.arg("--runtool").arg(&cargo_miri_path); // invoked with just a single path argument

if verbose {
eprintln!("[cargo-miri rustdoc] {:?}", cmd);
}

exec(cmd)
}

Expand All @@ -809,6 +948,30 @@ fn main() {
return;
}

// The way rustdoc invokes rustc is indistuingishable from the way cargo invokes rustdoc
// by the arguments alone, and we can't take from the args iterator in this case.
RalfJung marked this conversation as resolved.
Show resolved Hide resolved
// phase_cargo_rustdoc sets this environment variable to let us disambiguate here
let invoked_by_rustdoc = env::var_os("MIRI_CALLED_FROM_RUSTDOC").is_some();
if invoked_by_rustdoc {
// ...however, we then also see this variable when rustdoc invokes us as the testrunner!
// The runner is invoked as `$runtool ($runtool-arg)* output_file`;
// since we don't specify any runtool-args, and rustdoc supplies multiple arguments to
// the test-builder unconditionally, we can just check the number of remaining arguments:
if args.len() == 1 {
let arg = args.next().unwrap();
let binary = Path::new(&arg);
if binary.exists() {
phase_cargo_runner(binary, args);
} else {
show_error(format!("`cargo-miri` called with non-existing path argument `{}` in rustdoc mode; please invoke this binary through `cargo miri`", arg));
}
} else {
phase_cargo_rustc(args);
}

return;
}

// Dispatch to `cargo-miri` phase. There are three phases:
// - When we are called via `cargo miri`, we run as the frontend and invoke the underlying
// cargo. We set RUSTC_WRAPPER and CARGO_TARGET_RUNNER to ourselves.
Expand All @@ -821,16 +984,15 @@ fn main() {
Some("miri") => phase_cargo_miri(args),
Some("rustc") => phase_cargo_rustc(args),
Some(arg) => {
// We have to distinguish the "runner" and "rustfmt" cases.
// We have to distinguish the "runner" and "rustdoc" cases.
// As runner, the first argument is the binary (a file that should exist, with an absolute path);
// as rustfmt, the first argument is a flag (`--something`).
// as rustdoc, the first argument is a flag (`--something`).
let binary = Path::new(arg);
if binary.exists() {
assert!(!arg.starts_with("--")); // not a flag
phase_cargo_runner(binary, args);
} else if arg.starts_with("--") {
// We are rustdoc.
eprintln!("Running doctests is not currently supported by Miri.")
phase_cargo_rustdoc(arg, args);
} else {
show_error(format!("`cargo-miri` called with unexpected first argument `{}`; please only invoke this binary through `cargo miri`", arg));
}
Expand Down
19 changes: 12 additions & 7 deletions test-cargo-miri/run-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
and the working directory to contain the cargo-miri-test project.
'''

import sys, subprocess, os
import sys, subprocess, os, re

CGREEN = '\33[32m'
CBOLD = '\33[1m'
Expand All @@ -21,6 +21,10 @@ def cargo_miri(cmd):
args += ["--target", os.environ['MIRI_TEST_TARGET']]
return args

def normalize_stdout(str):
str = str.replace("src\\", "src/") # normalize paths across platforms
return re.sub("finished in \d+\.\d\ds", "finished in $TIME", str)

def test(name, cmd, stdout_ref, stderr_ref, stdin=b'', env={}):
print("Testing {}...".format(name))
## Call `cargo miri`, capture all output
Expand All @@ -36,7 +40,7 @@ def test(name, cmd, stdout_ref, stderr_ref, stdin=b'', env={}):
(stdout, stderr) = p.communicate(input=stdin)
stdout = stdout.decode("UTF-8")
stderr = stderr.decode("UTF-8")
if p.returncode == 0 and stdout == open(stdout_ref).read() and stderr == open(stderr_ref).read():
if p.returncode == 0 and normalize_stdout(stdout) == open(stdout_ref).read() and stderr == open(stderr_ref).read():
# All good!
return
# Show output
Expand Down Expand Up @@ -71,26 +75,27 @@ def test_cargo_miri_run():
def test_cargo_miri_test():
# rustdoc is not run on foreign targets
is_foreign = 'MIRI_TEST_TARGET' in os.environ
rustdoc_ref = "test.stderr-empty.ref" if is_foreign else "test.stderr-rustdoc.ref"
default_ref = "test.cross-target.stdout.ref" if is_foreign else "test.default.stdout.ref"
filter_ref = "test.filter.cross-target.stdout.ref" if is_foreign else "test.filter.stdout.ref"

test("`cargo miri test`",
cargo_miri("test"),
"test.default.stdout.ref", rustdoc_ref,
default_ref, "test.stderr-empty.ref",
env={'MIRIFLAGS': "-Zmiri-seed=feed"},
)
test("`cargo miri test` (no isolation)",
cargo_miri("test"),
"test.default.stdout.ref", rustdoc_ref,
default_ref, "test.stderr-empty.ref",
env={'MIRIFLAGS': "-Zmiri-disable-isolation"},
)
test("`cargo miri test` (raw-ptr tracking)",
cargo_miri("test"),
"test.default.stdout.ref", rustdoc_ref,
default_ref, "test.stderr-empty.ref",
Copy link
Member

Choose a reason for hiding this comment

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

Can you change one of these tests to also pass --no-doc, just to make sure that that actually has the intended effect?

env={'MIRIFLAGS': "-Zmiri-track-raw-pointers"},
)
test("`cargo miri test` (with filter)",
cargo_miri("test") + ["--", "--format=pretty", "le1"],
"test.filter.stdout.ref", rustdoc_ref,
filter_ref, "test.stderr-empty.ref",
)
test("`cargo miri test` (test target)",
cargo_miri("test") + ["--test", "test", "--", "--format=pretty"],
Expand Down
Loading