about summary refs log tree commit diff
diff options
context:
space:
mode:
authorJacob Pratt <jacob@jhpratt.dev>2025-09-04 01:43:21 -0400
committerGitHub <noreply@github.com>2025-09-04 01:43:21 -0400
commitbe1d82991024c6dd029e2aded934a2d6fcfadc13 (patch)
treeb23c807c4042dc60b6666411920ef6fa2b18d217
parent00d5dc5c9d0a304020c7eb75562a415cf0e23806 (diff)
parentc2ae2e03d2ab371adab0b16d0c2dba1b5f51e17c (diff)
downloadrust-be1d82991024c6dd029e2aded934a2d6fcfadc13.tar.gz
rust-be1d82991024c6dd029e2aded934a2d6fcfadc13.zip
Rollup merge of #146119 - Zalathar:capture, r=jieyouxu
compiletest: Implement an experimental `--new-output-capture` mode

Thanks to the efforts on rust-lang/rust#140192, compiletest no longer has an unstable dependency on libtest, but it still has an unstable dependency on `#![feature(internal_output_capture)]`. That makes building compiletest more complicated than for most other bootstrap tools.

This PR therefore adds opt-in support for an experimental compiletest mode that avoids the use of `internal_output_capture` APIs, and instead uses more mundane means to capture the output of individual test runners.

Each `TestCx` now contains `&dyn ConsoleOut` references for stdout and stderr. All print statements in `compiletests::runtest` have been replaced with `write!` or `writeln!` calls that explicitly write to one of those trait objects. The underlying implementation then forwards to `print!` or `eprint!` (for `--no-capture` or old-output-capture mode), or writes to a separate buffer (in new-output-capture mode).

---

Currently, new-output-capture is disabled by default. It can be explicitly enabled in one of two ways:

- When running `x test`, pass `--new-output-capture=on` as a *compiletest* argument (after `--`).
  - E.g. `x test ui -- --new-output-capture=on`.
  - The short form is `-Non` or `-Ny`.
- Set environment variable `COMPILETEST_NEW_OUTPUT_CAPTURE=on`.

After some amount of opt-in testing, new-output-capture will become the default (with a temporary opt-out). Eventually, old-output-capture and `#![feature(internal_output_capture)]` will be completely removed from compiletest.

r? jieyouxu
-rw-r--r--src/tools/compiletest/src/common.rs5
-rw-r--r--src/tools/compiletest/src/executor.rs88
-rw-r--r--src/tools/compiletest/src/lib.rs28
-rw-r--r--src/tools/compiletest/src/output_capture.rs52
-rw-r--r--src/tools/compiletest/src/runtest.rs96
-rw-r--r--src/tools/compiletest/src/runtest/codegen_units.rs30
-rw-r--r--src/tools/compiletest/src/runtest/compute_diff.rs7
-rw-r--r--src/tools/compiletest/src/runtest/crashes.rs8
-rw-r--r--src/tools/compiletest/src/runtest/debuginfo.rs20
-rw-r--r--src/tools/compiletest/src/runtest/incremental.rs2
-rw-r--r--src/tools/compiletest/src/runtest/mir_opt.rs2
-rw-r--r--src/tools/compiletest/src/runtest/rustdoc_json.rs4
-rw-r--r--src/tools/compiletest/src/runtest/ui.rs8
13 files changed, 275 insertions, 75 deletions
diff --git a/src/tools/compiletest/src/common.rs b/src/tools/compiletest/src/common.rs
index 89b1b4f84b6..62fdee98735 100644
--- a/src/tools/compiletest/src/common.rs
+++ b/src/tools/compiletest/src/common.rs
@@ -667,6 +667,10 @@ pub struct Config {
     /// to avoid `!nocapture` double-negatives.
     pub nocapture: bool,
 
+    /// True if the experimental new output-capture implementation should be
+    /// used, avoiding the need for `#![feature(internal_output_capture)]`.
+    pub new_output_capture: bool,
+
     /// Needed both to construct [`build_helper::git::GitConfig`].
     pub nightly_branch: String,
     pub git_merge_commit_email: String,
@@ -784,6 +788,7 @@ impl Config {
             builtin_cfg_names: Default::default(),
             supported_crate_types: Default::default(),
             nocapture: Default::default(),
+            new_output_capture: Default::default(),
             nightly_branch: Default::default(),
             git_merge_commit_email: Default::default(),
             profiler_runtime: Default::default(),
diff --git a/src/tools/compiletest/src/executor.rs b/src/tools/compiletest/src/executor.rs
index 5519ef1af1f..b0dc24798b6 100644
--- a/src/tools/compiletest/src/executor.rs
+++ b/src/tools/compiletest/src/executor.rs
@@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex, mpsc};
 use std::{env, hint, io, mem, panic, thread};
 
 use crate::common::{Config, TestPaths};
+use crate::output_capture::{self, ConsoleOut};
 use crate::panic_hook;
 
 mod deadline;
@@ -120,28 +121,28 @@ fn run_test_inner(
     runnable_test: RunnableTest,
     completion_sender: mpsc::Sender<TestCompletion>,
 ) {
-    let is_capture = !runnable_test.config.nocapture;
+    let capture = CaptureKind::for_config(&runnable_test.config);
 
     // Install a panic-capture buffer for use by the custom panic hook.
-    if is_capture {
+    if capture.should_set_panic_hook() {
         panic_hook::set_capture_buf(Default::default());
     }
-    let capture_buf = is_capture.then(|| Arc::new(Mutex::new(vec![])));
 
-    if let Some(capture_buf) = &capture_buf {
-        io::set_output_capture(Some(Arc::clone(capture_buf)));
+    if let CaptureKind::Old { ref buf } = capture {
+        io::set_output_capture(Some(Arc::clone(buf)));
     }
 
-    let panic_payload = panic::catch_unwind(move || runnable_test.run()).err();
+    let stdout = capture.stdout();
+    let stderr = capture.stderr();
+
+    let panic_payload = panic::catch_unwind(move || runnable_test.run(stdout, stderr)).err();
 
     if let Some(panic_buf) = panic_hook::take_capture_buf() {
         let panic_buf = panic_buf.lock().unwrap_or_else(|e| e.into_inner());
-        // For now, forward any captured panic message to (captured) stderr.
-        // FIXME(Zalathar): Once we have our own output-capture buffer for
-        // non-panic output, append the panic message to that buffer instead.
-        eprint!("{panic_buf}");
+        // Forward any captured panic message to (captured) stderr.
+        write!(stderr, "{panic_buf}");
     }
-    if is_capture {
+    if matches!(capture, CaptureKind::Old { .. }) {
         io::set_output_capture(None);
     }
 
@@ -152,11 +153,70 @@ fn run_test_inner(
             TestOutcome::Failed { message: Some("test did not panic as expected") }
         }
     };
-    let stdout = capture_buf.map(|mutex| mutex.lock().unwrap_or_else(|e| e.into_inner()).to_vec());
 
+    let stdout = capture.into_inner();
     completion_sender.send(TestCompletion { id, outcome, stdout }).unwrap();
 }
 
+enum CaptureKind {
+    /// Do not capture test-runner output, for `--no-capture`.
+    ///
+    /// (This does not affect `rustc` and other subprocesses spawned by test
+    /// runners, whose output is always captured.)
+    None,
+
+    /// Use the old output-capture implementation, which relies on the unstable
+    /// library feature `#![feature(internal_output_capture)]`.
+    Old { buf: Arc<Mutex<Vec<u8>>> },
+
+    /// Use the new output-capture implementation, which only uses stable Rust.
+    New { buf: output_capture::CaptureBuf },
+}
+
+impl CaptureKind {
+    fn for_config(config: &Config) -> Self {
+        if config.nocapture {
+            Self::None
+        } else if config.new_output_capture {
+            Self::New { buf: output_capture::CaptureBuf::new() }
+        } else {
+            // Create a capure buffer for `io::set_output_capture`.
+            Self::Old { buf: Default::default() }
+        }
+    }
+
+    fn should_set_panic_hook(&self) -> bool {
+        match self {
+            Self::None => false,
+            Self::Old { .. } => true,
+            Self::New { .. } => true,
+        }
+    }
+
+    fn stdout(&self) -> &dyn ConsoleOut {
+        self.capture_buf_or(&output_capture::Stdout)
+    }
+
+    fn stderr(&self) -> &dyn ConsoleOut {
+        self.capture_buf_or(&output_capture::Stderr)
+    }
+
+    fn capture_buf_or<'a>(&'a self, fallback: &'a dyn ConsoleOut) -> &'a dyn ConsoleOut {
+        match self {
+            Self::None | Self::Old { .. } => fallback,
+            Self::New { buf } => buf,
+        }
+    }
+
+    fn into_inner(self) -> Option<Vec<u8>> {
+        match self {
+            Self::None => None,
+            Self::Old { buf } => Some(buf.lock().unwrap_or_else(|e| e.into_inner()).to_vec()),
+            Self::New { buf } => Some(buf.into_inner().into()),
+        }
+    }
+}
+
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
 struct TestId(usize);
 
@@ -174,10 +234,12 @@ impl RunnableTest {
         Self { config, testpaths, revision }
     }
 
-    fn run(&self) {
+    fn run(&self, stdout: &dyn ConsoleOut, stderr: &dyn ConsoleOut) {
         __rust_begin_short_backtrace(|| {
             crate::runtest::run(
                 Arc::clone(&self.config),
+                stdout,
+                stderr,
                 &self.testpaths,
                 self.revision.as_deref(),
             );
diff --git a/src/tools/compiletest/src/lib.rs b/src/tools/compiletest/src/lib.rs
index fa84691a46f..f647f96a9bf 100644
--- a/src/tools/compiletest/src/lib.rs
+++ b/src/tools/compiletest/src/lib.rs
@@ -15,6 +15,7 @@ pub mod directives;
 pub mod errors;
 mod executor;
 mod json;
+mod output_capture;
 mod panic_hook;
 mod raise_fd_limit;
 mod read2;
@@ -177,6 +178,12 @@ pub fn parse_config(args: Vec<String>) -> Config {
         // FIXME: Temporarily retained so we can point users to `--no-capture`
         .optflag("", "nocapture", "")
         .optflag("", "no-capture", "don't capture stdout/stderr of tests")
+        .optopt(
+            "N",
+            "new-output-capture",
+            "enables or disables the new output-capture implementation",
+            "off|on",
+        )
         .optflag("", "profiler-runtime", "is the profiler runtime enabled for this target")
         .optflag("h", "help", "show this message")
         .reqopt("", "channel", "current Rust channel", "CHANNEL")
@@ -461,6 +468,14 @@ pub fn parse_config(args: Vec<String>) -> Config {
         supported_crate_types: OnceLock::new(),
 
         nocapture: matches.opt_present("no-capture"),
+        new_output_capture: {
+            let value = matches
+                .opt_str("new-output-capture")
+                .or_else(|| env::var("COMPILETEST_NEW_OUTPUT_CAPTURE").ok())
+                .unwrap_or_else(|| "off".to_owned());
+            parse_bool_option(&value)
+                .unwrap_or_else(|| panic!("unknown `--new-output-capture` value `{value}` given"))
+        },
 
         nightly_branch: matches.opt_str("nightly-branch").unwrap(),
         git_merge_commit_email: matches.opt_str("git-merge-commit-email").unwrap(),
@@ -476,6 +491,19 @@ pub fn parse_config(args: Vec<String>) -> Config {
     }
 }
 
+/// Parses the same set of boolean values accepted by rustc command-line arguments.
+///
+/// Accepting all of these values is more complicated than just picking one
+/// pair, but has the advantage that contributors who are used to rustc
+/// shouldn't have to think about which values are legal.
+fn parse_bool_option(value: &str) -> Option<bool> {
+    match value {
+        "off" | "no" | "n" | "false" => Some(false),
+        "on" | "yes" | "y" | "true" => Some(true),
+        _ => None,
+    }
+}
+
 pub fn opt_str(maybestr: &Option<String>) -> &str {
     match *maybestr {
         None => "(none)",
diff --git a/src/tools/compiletest/src/output_capture.rs b/src/tools/compiletest/src/output_capture.rs
new file mode 100644
index 00000000000..de1aea11ade
--- /dev/null
+++ b/src/tools/compiletest/src/output_capture.rs
@@ -0,0 +1,52 @@
+use std::fmt;
+use std::panic::RefUnwindSafe;
+use std::sync::Mutex;
+
+pub trait ConsoleOut: fmt::Debug + RefUnwindSafe {
+    fn write_fmt(&self, args: fmt::Arguments<'_>);
+}
+
+#[derive(Debug)]
+pub(crate) struct Stdout;
+
+impl ConsoleOut for Stdout {
+    fn write_fmt(&self, args: fmt::Arguments<'_>) {
+        print!("{args}");
+    }
+}
+
+#[derive(Debug)]
+pub(crate) struct Stderr;
+
+impl ConsoleOut for Stderr {
+    fn write_fmt(&self, args: fmt::Arguments<'_>) {
+        eprint!("{args}");
+    }
+}
+
+pub(crate) struct CaptureBuf {
+    inner: Mutex<String>,
+}
+
+impl CaptureBuf {
+    pub(crate) fn new() -> Self {
+        Self { inner: Mutex::new(String::new()) }
+    }
+
+    pub(crate) fn into_inner(self) -> String {
+        self.inner.into_inner().unwrap_or_else(|e| e.into_inner())
+    }
+}
+
+impl fmt::Debug for CaptureBuf {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("CaptureBuf").finish_non_exhaustive()
+    }
+}
+
+impl ConsoleOut for CaptureBuf {
+    fn write_fmt(&self, args: fmt::Arguments<'_>) {
+        let mut s = self.inner.lock().unwrap_or_else(|e| e.into_inner());
+        <String as fmt::Write>::write_fmt(&mut s, args).unwrap();
+    }
+}
diff --git a/src/tools/compiletest/src/runtest.rs b/src/tools/compiletest/src/runtest.rs
index 867624cc8fa..89fb8eb4357 100644
--- a/src/tools/compiletest/src/runtest.rs
+++ b/src/tools/compiletest/src/runtest.rs
@@ -23,6 +23,7 @@ use crate::common::{
 };
 use crate::directives::TestProps;
 use crate::errors::{Error, ErrorKind, load_errors};
+use crate::output_capture::ConsoleOut;
 use crate::read2::{Truncated, read2_abbreviated};
 use crate::runtest::compute_diff::{DiffLine, make_diff, write_diff, write_filtered_diff};
 use crate::util::{Utf8PathBufExt, add_dylib_path, static_regex};
@@ -108,7 +109,13 @@ fn dylib_name(name: &str) -> String {
     format!("{}{name}.{}", std::env::consts::DLL_PREFIX, std::env::consts::DLL_EXTENSION)
 }
 
-pub fn run(config: Arc<Config>, testpaths: &TestPaths, revision: Option<&str>) {
+pub fn run(
+    config: Arc<Config>,
+    stdout: &dyn ConsoleOut,
+    stderr: &dyn ConsoleOut,
+    testpaths: &TestPaths,
+    revision: Option<&str>,
+) {
     match &*config.target {
         "arm-linux-androideabi"
         | "armv7-linux-androideabi"
@@ -131,7 +138,7 @@ pub fn run(config: Arc<Config>, testpaths: &TestPaths, revision: Option<&str>) {
 
     if config.verbose {
         // We're going to be dumping a lot of info. Start on a new line.
-        print!("\n\n");
+        write!(stdout, "\n\n");
     }
     debug!("running {}", testpaths.file);
     let mut props = TestProps::from_file(&testpaths.file, revision, &config);
@@ -143,7 +150,7 @@ pub fn run(config: Arc<Config>, testpaths: &TestPaths, revision: Option<&str>) {
         props.incremental_dir = Some(incremental_dir(&config, testpaths, revision));
     }
 
-    let cx = TestCx { config: &config, props: &props, testpaths, revision };
+    let cx = TestCx { config: &config, stdout, stderr, props: &props, testpaths, revision };
 
     if let Err(e) = create_dir_all(&cx.output_base_dir()) {
         panic!("failed to create output base directory {}: {e}", cx.output_base_dir());
@@ -162,6 +169,8 @@ pub fn run(config: Arc<Config>, testpaths: &TestPaths, revision: Option<&str>) {
             revision_props.incremental_dir = props.incremental_dir.clone();
             let rev_cx = TestCx {
                 config: &config,
+                stdout,
+                stderr,
                 props: &revision_props,
                 testpaths,
                 revision: Some(revision),
@@ -212,6 +221,8 @@ pub fn compute_stamp_hash(config: &Config) -> String {
 #[derive(Copy, Clone, Debug)]
 struct TestCx<'test> {
     config: &'test Config,
+    stdout: &'test dyn ConsoleOut,
+    stderr: &'test dyn ConsoleOut,
     props: &'test TestProps,
     testpaths: &'test TestPaths,
     revision: Option<&'test str>,
@@ -603,7 +614,8 @@ impl<'test> TestCx<'test> {
             );
         } else {
             for pattern in missing_patterns {
-                println!(
+                writeln!(
+                    self.stdout,
                     "\n{prefix}: error pattern '{pattern}' not found!",
                     prefix = self.error_prefix()
                 );
@@ -783,7 +795,8 @@ impl<'test> TestCx<'test> {
                 };
                 format!("{file_name}:{line_num}{opt_col_num}")
             };
-            let print_error = |e| println!("{}: {}: {}", line_str(e), e.kind, e.msg.cyan());
+            let print_error =
+                |e| writeln!(self.stdout, "{}: {}: {}", line_str(e), e.kind, e.msg.cyan());
             let push_suggestion =
                 |suggestions: &mut Vec<_>, e: &Error, kind, line, msg, color, rank| {
                     let mut ret = String::new();
@@ -811,7 +824,7 @@ impl<'test> TestCx<'test> {
                 if let Some(&(_, top_rank)) = suggestions.first() {
                     for (suggestion, rank) in suggestions {
                         if rank == top_rank {
-                            println!("  {} {suggestion}", prefix.color(color));
+                            writeln!(self.stdout, "  {} {suggestion}", prefix.color(color));
                         }
                     }
                 }
@@ -824,7 +837,8 @@ impl<'test> TestCx<'test> {
             // - only known line - meh, but suggested
             // - others are not worth suggesting
             if !unexpected.is_empty() {
-                println!(
+                writeln!(
+                    self.stdout,
                     "\n{prefix}: {n} diagnostics reported in JSON output but not expected in test file",
                     prefix = self.error_prefix(),
                     n = unexpected.len(),
@@ -858,7 +872,8 @@ impl<'test> TestCx<'test> {
                 }
             }
             if !not_found.is_empty() {
-                println!(
+                writeln!(
+                    self.stdout,
                     "\n{prefix}: {n} diagnostics expected in test file but not reported in JSON output",
                     prefix = self.error_prefix(),
                     n = not_found.len(),
@@ -978,6 +993,8 @@ impl<'test> TestCx<'test> {
                     self.props.from_aux_file(&aux_testpaths.file, self.revision, self.config);
                 let aux_cx = TestCx {
                     config: self.config,
+                    stdout: self.stdout,
+                    stderr: self.stderr,
                     props: &props_for_aux,
                     testpaths: &aux_testpaths,
                     revision: self.revision,
@@ -1343,6 +1360,8 @@ impl<'test> TestCx<'test> {
         let aux_output = TargetLocation::ThisDirectory(aux_dir.clone());
         let aux_cx = TestCx {
             config: self.config,
+            stdout: self.stdout,
+            stderr: self.stderr,
             props: &aux_props,
             testpaths: &aux_testpaths,
             revision: self.revision,
@@ -1948,11 +1967,11 @@ impl<'test> TestCx<'test> {
         } else {
             path.file_name().unwrap().into()
         };
-        println!("------{proc_name} stdout------------------------------");
-        println!("{}", out);
-        println!("------{proc_name} stderr------------------------------");
-        println!("{}", err);
-        println!("------------------------------------------");
+        writeln!(self.stdout, "------{proc_name} stdout------------------------------");
+        writeln!(self.stdout, "{}", out);
+        writeln!(self.stdout, "------{proc_name} stderr------------------------------");
+        writeln!(self.stdout, "{}", err);
+        writeln!(self.stdout, "------------------------------------------");
     }
 
     fn dump_output_file(&self, out: &str, extension: &str) {
@@ -2014,7 +2033,7 @@ impl<'test> TestCx<'test> {
         debug!("{message}");
         if self.config.verbose {
             // Note: `./x test ... --verbose --no-capture` is needed to see this print.
-            println!("{message}");
+            writeln!(self.stdout, "{message}");
         }
     }
 
@@ -2030,7 +2049,7 @@ impl<'test> TestCx<'test> {
 
     #[track_caller]
     fn fatal(&self, err: &str) -> ! {
-        println!("\n{prefix}: {err}", prefix = self.error_prefix());
+        writeln!(self.stdout, "\n{prefix}: {err}", prefix = self.error_prefix());
         error!("fatal error, panic: {:?}", err);
         panic!("fatal error");
     }
@@ -2048,15 +2067,15 @@ impl<'test> TestCx<'test> {
         proc_res: &ProcRes,
         callback_before_unwind: impl FnOnce(),
     ) -> ! {
-        println!("\n{prefix}: {err}", prefix = self.error_prefix());
+        writeln!(self.stdout, "\n{prefix}: {err}", prefix = self.error_prefix());
 
         // Some callers want to print additional notes after the main error message.
         if let Some(note) = extra_note {
-            println!("{note}");
+            writeln!(self.stdout, "{note}");
         }
 
         // Print the details and output of the subprocess that caused this test to fail.
-        println!("{}", proc_res.format_info());
+        writeln!(self.stdout, "{}", proc_res.format_info());
 
         // Some callers want print more context or show a custom diff before the unwind occurs.
         callback_before_unwind();
@@ -2126,7 +2145,7 @@ impl<'test> TestCx<'test> {
         if !self.config.has_html_tidy {
             return;
         }
-        println!("info: generating a diff against nightly rustdoc");
+        writeln!(self.stdout, "info: generating a diff against nightly rustdoc");
 
         let suffix =
             self.safe_revision().map_or("nightly".into(), |path| path.to_owned() + "-nightly");
@@ -2162,7 +2181,7 @@ impl<'test> TestCx<'test> {
 
         let proc_res = new_rustdoc.document(&compare_dir, &new_rustdoc.testpaths);
         if !proc_res.status.success() {
-            eprintln!("failed to run nightly rustdoc");
+            writeln!(self.stderr, "failed to run nightly rustdoc");
             return;
         }
 
@@ -2207,6 +2226,7 @@ impl<'test> TestCx<'test> {
         let diff_filename = format!("build/tmp/rustdoc-compare-{}.diff", std::process::id());
 
         if !write_filtered_diff(
+            self,
             &diff_filename,
             out_dir,
             &compare_dir,
@@ -2227,7 +2247,7 @@ impl<'test> TestCx<'test> {
         if let Some(pager) = pager {
             let pager = pager.trim();
             if self.config.verbose {
-                eprintln!("using pager {}", pager);
+                writeln!(self.stderr, "using pager {}", pager);
             }
             let output = Command::new(pager)
                 // disable paging; we want this to be non-interactive
@@ -2238,8 +2258,8 @@ impl<'test> TestCx<'test> {
                 .output()
                 .unwrap();
             assert!(output.status.success());
-            println!("{}", String::from_utf8_lossy(&output.stdout));
-            eprintln!("{}", String::from_utf8_lossy(&output.stderr));
+            writeln!(self.stdout, "{}", String::from_utf8_lossy(&output.stdout));
+            writeln!(self.stderr, "{}", String::from_utf8_lossy(&output.stderr));
         } else {
             warning!("no pager configured, falling back to unified diff");
             help!(
@@ -2254,7 +2274,7 @@ impl<'test> TestCx<'test> {
                 match diff.read_until(b'\n', &mut line) {
                     Ok(0) => break,
                     Ok(_) => {}
-                    Err(e) => eprintln!("ERROR: {:?}", e),
+                    Err(e) => writeln!(self.stderr, "ERROR: {:?}", e),
                 }
                 match String::from_utf8(line.clone()) {
                     Ok(line) => {
@@ -2802,11 +2822,11 @@ impl<'test> TestCx<'test> {
         if let Err(err) = fs::write(&actual_path, &actual) {
             self.fatal(&format!("failed to write {stream} to `{actual_path}`: {err}",));
         }
-        println!("Saved the actual {stream} to `{actual_path}`");
+        writeln!(self.stdout, "Saved the actual {stream} to `{actual_path}`");
 
         if !self.config.bless {
             if expected.is_empty() {
-                println!("normalized {}:\n{}\n", stream, actual);
+                writeln!(self.stdout, "normalized {}:\n{}\n", stream, actual);
             } else {
                 self.show_diff(
                     stream,
@@ -2830,14 +2850,15 @@ impl<'test> TestCx<'test> {
                 if let Err(err) = fs::write(&expected_path, &actual) {
                     self.fatal(&format!("failed to write {stream} to `{expected_path}`: {err}"));
                 }
-                println!(
+                writeln!(
+                    self.stdout,
                     "Blessing the {stream} of `{test_name}` as `{expected_path}`",
                     test_name = self.testpaths.file
                 );
             }
         }
 
-        println!("\nThe actual {stream} differed from the expected {stream}");
+        writeln!(self.stdout, "\nThe actual {stream} differed from the expected {stream}");
 
         if self.config.bless { CompareOutcome::Blessed } else { CompareOutcome::Differed }
     }
@@ -2852,7 +2873,7 @@ impl<'test> TestCx<'test> {
         actual: &str,
         actual_unnormalized: &str,
     ) {
-        eprintln!("diff of {stream}:\n");
+        writeln!(self.stderr, "diff of {stream}:\n");
         if let Some(diff_command) = self.config.diff_command.as_deref() {
             let mut args = diff_command.split_whitespace();
             let name = args.next().unwrap();
@@ -2864,11 +2885,11 @@ impl<'test> TestCx<'test> {
                 }
                 Ok(output) => {
                     let output = String::from_utf8_lossy(&output.stdout);
-                    eprint!("{output}");
+                    write!(self.stderr, "{output}");
                 }
             }
         } else {
-            eprint!("{}", write_diff(expected, actual, 3));
+            write!(self.stderr, "{}", write_diff(expected, actual, 3));
         }
 
         // NOTE: argument order is important, we need `actual` to be on the left so the line number match up when we compare it to `actual_unnormalized` below.
@@ -2906,9 +2927,16 @@ impl<'test> TestCx<'test> {
             && !mismatches_unnormalized.is_empty()
             && !mismatches_normalized.is_empty()
         {
-            eprintln!("Note: some mismatched output was normalized before being compared");
+            writeln!(
+                self.stderr,
+                "Note: some mismatched output was normalized before being compared"
+            );
             // FIXME: respect diff_command
-            eprint!("{}", write_diff(&mismatches_unnormalized, &mismatches_normalized, 0));
+            write!(
+                self.stderr,
+                "{}",
+                write_diff(&mismatches_unnormalized, &mismatches_normalized, 0)
+            );
         }
     }
 
@@ -2986,7 +3014,7 @@ impl<'test> TestCx<'test> {
         fs::create_dir_all(&incremental_dir).unwrap();
 
         if self.config.verbose {
-            println!("init_incremental_test: incremental_dir={incremental_dir}");
+            writeln!(self.stdout, "init_incremental_test: incremental_dir={incremental_dir}");
         }
     }
 }
diff --git a/src/tools/compiletest/src/runtest/codegen_units.rs b/src/tools/compiletest/src/runtest/codegen_units.rs
index 44ddcb1d288..16c251c3c9e 100644
--- a/src/tools/compiletest/src/runtest/codegen_units.rs
+++ b/src/tools/compiletest/src/runtest/codegen_units.rs
@@ -62,13 +62,13 @@ impl TestCx<'_> {
         if !missing.is_empty() {
             missing.sort();
 
-            println!("\nThese items should have been contained but were not:\n");
+            writeln!(self.stdout, "\nThese items should have been contained but were not:\n");
 
             for item in &missing {
-                println!("{}", item);
+                writeln!(self.stdout, "{}", item);
             }
 
-            println!("\n");
+            writeln!(self.stdout, "\n");
         }
 
         if !unexpected.is_empty() {
@@ -78,24 +78,32 @@ impl TestCx<'_> {
                 sorted
             };
 
-            println!("\nThese items were contained but should not have been:\n");
+            writeln!(self.stdout, "\nThese items were contained but should not have been:\n");
 
             for item in sorted {
-                println!("{}", item);
+                writeln!(self.stdout, "{}", item);
             }
 
-            println!("\n");
+            writeln!(self.stdout, "\n");
         }
 
         if !wrong_cgus.is_empty() {
             wrong_cgus.sort_by_key(|pair| pair.0.name.clone());
-            println!("\nThe following items were assigned to wrong codegen units:\n");
+            writeln!(self.stdout, "\nThe following items were assigned to wrong codegen units:\n");
 
             for &(ref expected_item, ref actual_item) in &wrong_cgus {
-                println!("{}", expected_item.name);
-                println!("  expected: {}", codegen_units_to_str(&expected_item.codegen_units));
-                println!("  actual:   {}", codegen_units_to_str(&actual_item.codegen_units));
-                println!();
+                writeln!(self.stdout, "{}", expected_item.name);
+                writeln!(
+                    self.stdout,
+                    "  expected: {}",
+                    codegen_units_to_str(&expected_item.codegen_units)
+                );
+                writeln!(
+                    self.stdout,
+                    "  actual:   {}",
+                    codegen_units_to_str(&actual_item.codegen_units)
+                );
+                writeln!(self.stdout);
             }
         }
 
diff --git a/src/tools/compiletest/src/runtest/compute_diff.rs b/src/tools/compiletest/src/runtest/compute_diff.rs
index 509e7e11703..3363127b3ea 100644
--- a/src/tools/compiletest/src/runtest/compute_diff.rs
+++ b/src/tools/compiletest/src/runtest/compute_diff.rs
@@ -3,6 +3,8 @@ use std::fs::{File, FileType};
 
 use camino::Utf8Path;
 
+use crate::runtest::TestCx;
+
 #[derive(Debug, PartialEq)]
 pub enum DiffLine {
     Context(String),
@@ -112,6 +114,7 @@ pub(crate) fn write_diff(expected: &str, actual: &str, context_size: usize) -> S
 ///
 /// Returns whether any data was actually written.
 pub(crate) fn write_filtered_diff<Filter>(
+    cx: &TestCx<'_>,
     diff_filename: &str,
     out_dir: &Utf8Path,
     compare_dir: &Utf8Path,
@@ -147,11 +150,11 @@ where
     }
 
     if !wrote_data {
-        println!("note: diff is identical to nightly rustdoc");
+        writeln!(cx.stdout, "note: diff is identical to nightly rustdoc");
         assert!(diff_output.metadata().unwrap().len() == 0);
         return false;
     } else if verbose {
-        eprintln!("printing diff:");
+        writeln!(cx.stderr, "printing diff:");
         let mut buf = Vec::new();
         diff_output.read_to_end(&mut buf).unwrap();
         std::io::stderr().lock().write_all(&mut buf).unwrap();
diff --git a/src/tools/compiletest/src/runtest/crashes.rs b/src/tools/compiletest/src/runtest/crashes.rs
index da1e74b4a56..0aae7eaa39c 100644
--- a/src/tools/compiletest/src/runtest/crashes.rs
+++ b/src/tools/compiletest/src/runtest/crashes.rs
@@ -6,10 +6,10 @@ impl TestCx<'_> {
         let proc_res = self.compile_test(WillExecute::No, self.should_emit_metadata(pm));
 
         if std::env::var("COMPILETEST_VERBOSE_CRASHES").is_ok() {
-            eprintln!("{}", proc_res.status);
-            eprintln!("{}", proc_res.stdout);
-            eprintln!("{}", proc_res.stderr);
-            eprintln!("{}", proc_res.cmdline);
+            writeln!(self.stderr, "{}", proc_res.status);
+            writeln!(self.stderr, "{}", proc_res.stdout);
+            writeln!(self.stderr, "{}", proc_res.stderr);
+            writeln!(self.stderr, "{}", proc_res.cmdline);
         }
 
         // if a test does not crash, consider it an error
diff --git a/src/tools/compiletest/src/runtest/debuginfo.rs b/src/tools/compiletest/src/runtest/debuginfo.rs
index 88d022b8bba..071c0863b7e 100644
--- a/src/tools/compiletest/src/runtest/debuginfo.rs
+++ b/src/tools/compiletest/src/runtest/debuginfo.rs
@@ -245,7 +245,7 @@ impl TestCx<'_> {
                 cmdline,
             };
             if adb.kill().is_err() {
-                println!("Adb process is already finished.");
+                writeln!(self.stdout, "Adb process is already finished.");
             }
         } else {
             let rust_pp_module_abs_path = self.config.src_root.join("src").join("etc");
@@ -256,7 +256,11 @@ impl TestCx<'_> {
 
             match self.config.gdb_version {
                 Some(version) => {
-                    println!("NOTE: compiletest thinks it is using GDB version {}", version);
+                    writeln!(
+                        self.stdout,
+                        "NOTE: compiletest thinks it is using GDB version {}",
+                        version
+                    );
 
                     if !self.props.disable_gdb_pretty_printers
                         && version > extract_gdb_version("7.4").unwrap()
@@ -278,7 +282,8 @@ impl TestCx<'_> {
                     }
                 }
                 _ => {
-                    println!(
+                    writeln!(
+                        self.stdout,
                         "NOTE: compiletest does not know which version of \
                          GDB it is using"
                     );
@@ -376,10 +381,15 @@ impl TestCx<'_> {
 
         match self.config.lldb_version {
             Some(ref version) => {
-                println!("NOTE: compiletest thinks it is using LLDB version {}", version);
+                writeln!(
+                    self.stdout,
+                    "NOTE: compiletest thinks it is using LLDB version {}",
+                    version
+                );
             }
             _ => {
-                println!(
+                writeln!(
+                    self.stdout,
                     "NOTE: compiletest does not know which version of \
                      LLDB it is using"
                 );
diff --git a/src/tools/compiletest/src/runtest/incremental.rs b/src/tools/compiletest/src/runtest/incremental.rs
index 90cff6bab4d..44eb80300c3 100644
--- a/src/tools/compiletest/src/runtest/incremental.rs
+++ b/src/tools/compiletest/src/runtest/incremental.rs
@@ -30,7 +30,7 @@ impl TestCx<'_> {
         assert!(incremental_dir.exists(), "init_incremental_test failed to create incremental dir");
 
         if self.config.verbose {
-            print!("revision={:?} props={:#?}", revision, self.props);
+            write!(self.stdout, "revision={:?} props={:#?}", revision, self.props);
         }
 
         if revision.starts_with("cpass") {
diff --git a/src/tools/compiletest/src/runtest/mir_opt.rs b/src/tools/compiletest/src/runtest/mir_opt.rs
index 55043bf4bc2..94487926383 100644
--- a/src/tools/compiletest/src/runtest/mir_opt.rs
+++ b/src/tools/compiletest/src/runtest/mir_opt.rs
@@ -80,7 +80,7 @@ impl TestCx<'_> {
                 }
                 let expected_string = fs::read_to_string(&expected_file).unwrap();
                 if dumped_string != expected_string {
-                    print!("{}", write_diff(&expected_string, &dumped_string, 3));
+                    write!(self.stdout, "{}", write_diff(&expected_string, &dumped_string, 3));
                     panic!(
                         "Actual MIR output differs from expected MIR output {}",
                         expected_file.display()
diff --git a/src/tools/compiletest/src/runtest/rustdoc_json.rs b/src/tools/compiletest/src/runtest/rustdoc_json.rs
index 083398f9274..b8da6e2ac52 100644
--- a/src/tools/compiletest/src/runtest/rustdoc_json.rs
+++ b/src/tools/compiletest/src/runtest/rustdoc_json.rs
@@ -30,8 +30,8 @@ impl TestCx<'_> {
 
         if !res.status.success() {
             self.fatal_proc_rec_general("jsondocck failed!", None, &res, || {
-                println!("Rustdoc Output:");
-                println!("{}", proc_res.format_info());
+                writeln!(self.stdout, "Rustdoc Output:");
+                writeln!(self.stdout, "{}", proc_res.format_info());
             })
         }
 
diff --git a/src/tools/compiletest/src/runtest/ui.rs b/src/tools/compiletest/src/runtest/ui.rs
index 40b0ee0a399..d683a325c86 100644
--- a/src/tools/compiletest/src/runtest/ui.rs
+++ b/src/tools/compiletest/src/runtest/ui.rs
@@ -115,10 +115,14 @@ impl TestCx<'_> {
         }
 
         if errors > 0 {
-            println!("To update references, rerun the tests and pass the `--bless` flag");
+            writeln!(
+                self.stdout,
+                "To update references, rerun the tests and pass the `--bless` flag"
+            );
             let relative_path_to_file =
                 self.testpaths.relative_dir.join(self.testpaths.file.file_name().unwrap());
-            println!(
+            writeln!(
+                self.stdout,
                 "To only update this specific test, also pass `--test-args {}`",
                 relative_path_to_file,
             );