Repository navigation
close_output test is randomly failing #8564
Description
TLDR: Should we run some flaky tests single-threaded? (Nope)
The build::close_output test is randomly failing on CI. There were some fixes applied in #8286 in May 26, but there appears to be more recent failures:
rust-lang/rust#74312 (comment)
rust-lang/rust#74408 (comment)
rust-lang/rust#74908 (comment)
rust-lang/rust#74923 (https://github.com/rust-lang-ci/rust/runs/924743383)
The failure is:
---- build::close_output stdout ----
thread 'build::close_output' panicked at 'assertion failed: !status.success()', src/tools/cargo/tests/testsuite/build.rs:5016:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
I am uncertain how this is possible, so maybe someone could double check that what I wrote makes sense. The test covers what happens when stdout or stderr is closed in the middle of the build. It uses a proc-macro as a sync point so that the test can know when compilation has started, and to emit data to stdout or stderr during the build. It should follow this sequence:
- Starts a TCP server.
- Starts the build.
- The proc-macro starts building.
- The proc-macro connects to the TCP server, and waits for the test to tell it it is OK to continue.
- Test receives connection from proc-macro
- Test closes stdout.
- Test tells proc-macro to continue.
- Proc-macro starts spewing stuff to stdout to cargo, which through the internal job queue ends up attempting to write to stdout. Since stdout was closed in step 6, this should fail.
- Cargo should exit with an error after rustc is done.
For some reason, at step 8, it successfully writes to stdout, and step 9 returns success.
I've been doing a few tests, and it gets worse based on the number of concurrent tests running. When run single threaded, I cannot get it to fail (even with the system under heavy load).
I'm feeling this is somewhat related to #7858. Is there still a race condition, even with atomic O_CLOEXEC? That is, AIUI, the file descriptors are still inherited across fork, and only closed when exec is called. If so, then there is a small window where the file descriptors have extra duplicates which prevent them from fully closing immediately.
I'm thinking a simple solution would be to isolate these tests into a separate test executable which runs with (Testing shows this probably won't fix this test.)--test-threads=1 (or maybe a simple no-harness test?). This should prevent concurrent tests from interfering with one another. The downside is that this makes it more cumbersome to run all of the test suite.
Activity
ehuss commentedon Jul 30, 2020
Ugh, I just encountered a failure with it running in isolation without any threads...
alexcrichton commentedon Jul 30, 2020
I can't seem to reproduce this locally, at least not when running all tests in a loop that contain
build::. Can you try adding some logging to confirm your suspicion of the series of events?Otherwise my only real guess is that the pipe fills up before returning an error, but local tests show that doesn't happen so I don't think this is right. Especially if it happens without any threads that is indeed very odd...
One thing that may help is to
panic!()at the end of the proc macro to force compilation to fail, and then testing the output may help diagnose?ehuss commentedon Jul 30, 2020
I have instrumented it by writing to a file to verify what is happening. It seems to be working in the sequence I expect, and Cargo is sometimes successfully writing to stdout, even though I think it should be closed. Unless I have some misunderstanding about how pipes work (I've been assuming that after a successful close, the other side should be closed immediately assuming there aren't any duplicates of the fd). I keep thinking the most likely cause is a duplicate fd somewhere, but I can't find it.
To repro, here's a change that can make it occur much faster:
The actual count there shouldn't matter. After stdout is closed, any write should fail. And because the proc-macro and test are synchronized, it is guaranteed that the write comes after the close.
For doing single-threaded testing, I build the test and run this:
For me, this can fail anywhere from a few seconds to a few minutes. If you want a near-guaranteed failure, run several copies in parallel. I have a macro in my editor which adds something like the following:
I usually use a few hundred copies, but 16 seems to be enough here. Run
cargo test --test testsuite -- build::close_output.Here's a copy of the instrumentation from a failed run:
Hopefully that makes sense.
TESTis a log message from the test itself,PMis from the proc-macro, andCARGOis from cargo. "read some data" is from cargo'sread2from the pipe to rustc. Everything there is to be expected except for "CARGO: writing to stdout" should be followed with an error message that it failed to write.Compared to successful runs (where Cargo returns the expected error), the logging looks mostly the same. Some messages are rearranged due to process/thread scheduling, but the important lines are in the expected order.
Thanks for helping to take a look! I keep feeling like I am missing something obvious, or have some fundamental misunderstanding about pipes, or how the
Commandstruct spawns processes.ehuss commentedon Jul 30, 2020
Oh, and I've actually been doing most of my testing on a fast linux machine, which seems to repro a little more easily than on my mac laptop (which also can fail, but not as easily). I just ran the test on my 12-thread mac and it failed 10 out of 400 runs, whereas on my 32-thread linux system, it failed 298 out of 400 runs.
(This is with the
for i in 0..1change mentioned above.)alexcrichton commentedon Jul 30, 2020
Ok so I have some explanations, but not others.
If you change it to only print one item, e.g. change the loop to
for i in 0..1, I can see the race condition. What's happening is that we can close stdout both before and after Cargo printshello stdout. We're only synchronizing with the proc-macro, not Cargo's printing, so we know that rustc has printedhello stdoutwhen we close stdout but we don't actually know if Cargo has printed that out. Due to flushing behavior inLineWriterif you write a line when stdout is closed then it will successfully eat one line, but all future writes/flushes will fail. The final flush when the main thread exits actually fails, but we never see that error. So what happens here is that if Cargo writeshello stdout, then stdout is closed, then Cargo writes0\n, then that will succeed and Cargo won't ever see an error. It will, however, see an error if anything else is written. If Cargo's stdout is closed before all writes, however, thenhello stdoutwill write successfully and then the0\nwrite will trigger the error.That explains why when changing to
0..1from0..1000the test is flaky. I don't know why with 1000 writes we see the same issue though. We're still not synchronizing with Cargo's print of the original message, just the proc macro. With 1000 writes Cargo is guaranteed to make multiple writes which should see the eventual error inLineWriter. With0..2I'm unable to reproduce this issue...So to confirm, can you reproduce with
0..2? (or anything bigger than 1)ehuss commentedon Aug 3, 2020
Hm, sorry for the misleading direction. I wasn't aware of that interaction with
LineWriter. However, I'm quite easily able to reproduce with0..10. I ran tests for several hours today with0..10000, but I wasn't able to repro. I thought I had done that in the past, but my memory is foggy.My original intent with the 10,000 was just to ensure there wasn't any silliness with
PIPE_BUF, but it wasn't based on any evidence.I'll continue poking on this.
ehuss commentedon Aug 4, 2020
I'm returning to my original hypothesis that the issue is the file descriptor staying open because other threads are forking processes in the background, and there's a small window where the fd is duplicated. I'm able to repro with the following patch, running 1 test at a time (though it takes a while):
I'm not able to repro without other threads running in the background.
@alexcrichton I have two questions:
Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz. The other hardware that is often used isIntel(R) Xeon(R) Platinum 8171M CPU @ 2.60GHzwhich is a much newer/beefier system. However, with only 4 data points, it might be just a coincidence.alexcrichton commentedon Aug 4, 2020
I do actually always forget that CLOEXEC is, well, on exec not fork. Before we dive into this hypothesis though I'm curious:
--test-threads 1though you say you were able to reproduce, so where could the concurrent fork have happened?ehuss commentedon Aug 4, 2020
That was when I had
0..1, which was probably related to theLineWriterbehavior.Hm, good point. I did some tests, and I'm able to write 64KB to stdout or stderr before it starts blocking. I would have expected 4K. TIL, according to pipe(7), the buffer is 16 pages.
So, maybe the answer here is to just print more than 64KB of data?
alexcrichton commentedon Aug 4, 2020
Ok, that makes sense with
--test-threads=1. Otherwise yeah I think the best solution might be to write >64kb of data, we could probably write a megabyte or so just to be safe, because nothing should buffer that much. It does mean that the test will block unnecessarily while we wait for some other random test to finish, but that seems ok.Auto merge of #8587 - ehuss:close_output-fix, r=alexcrichton