| // Copyright 2016 The Bazel Authors. All rights reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| package com.google.devtools.build.lib.runtime; |
| |
| import static com.google.common.truth.Truth.assertThat; |
| import static com.google.common.truth.Truth.assertWithMessage; |
| |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.util.concurrent.Futures; |
| import com.google.common.util.concurrent.SettableFuture; |
| import com.google.devtools.build.lib.analysis.BlazeDirectories; |
| import com.google.devtools.build.lib.analysis.ConfiguredRuleClassProvider; |
| import com.google.devtools.build.lib.analysis.ServerDirectories; |
| import com.google.devtools.build.lib.analysis.config.BuildOptions; |
| import com.google.devtools.build.lib.analysis.config.CoreOptions; |
| import com.google.devtools.build.lib.analysis.test.TestConfiguration; |
| import com.google.devtools.build.lib.testutil.Scratch; |
| import com.google.devtools.build.lib.testutil.TestConstants; |
| import com.google.devtools.build.lib.testutil.TestUtils; |
| import com.google.devtools.build.lib.util.AbruptExitException; |
| import com.google.devtools.build.lib.util.ExitCode; |
| import com.google.devtools.build.lib.util.io.OutErr; |
| import com.google.devtools.common.options.Option; |
| import com.google.devtools.common.options.OptionDocumentationCategory; |
| import com.google.devtools.common.options.OptionEffectTag; |
| import com.google.devtools.common.options.OptionsBase; |
| import com.google.devtools.common.options.OptionsParser; |
| import com.google.devtools.common.options.OptionsParsingResult; |
| import java.util.concurrent.ArrayBlockingQueue; |
| import java.util.concurrent.BlockingQueue; |
| import java.util.concurrent.BrokenBarrierException; |
| import java.util.concurrent.Callable; |
| import java.util.concurrent.CyclicBarrier; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.Future; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| import java.util.concurrent.atomic.AtomicReference; |
| import org.junit.After; |
| import org.junit.Before; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.junit.runners.JUnit4; |
| |
| /** Tests of CommandEnvironment's command-interrupting exit functionality. */ |
| @RunWith(JUnit4.class) |
| public final class CommandInterruptionTest { |
| |
| /** Options class to pass configuration to our dummy wait command. */ |
| public static class WaitOptions extends OptionsBase { |
| public WaitOptions() {} |
| |
| @Option( |
| name = "expect_interruption", |
| documentationCategory = OptionDocumentationCategory.UNCATEGORIZED, |
| effectTags = {OptionEffectTag.NO_OP}, |
| defaultValue = "false" |
| ) |
| public boolean expectInterruption; |
| } |
| |
| /** |
| * Command which retrieves an exit code off the queue and returns it, or INTERRUPTED if |
| * interrupted more than --expect_interruptions times while waiting. |
| */ |
| @Command( |
| name = "snooze", |
| shortDescription = "", |
| help = "", |
| options = {WaitOptions.class} |
| ) |
| private static final class WaitForCompletionCommand implements BlazeCommand { |
| private final AtomicBoolean isTestShuttingDown; |
| private final AtomicReference<SettableFuture<CommandState>> commandStateHandoff; |
| |
| public WaitForCompletionCommand(AtomicBoolean isTestShuttingDown) { |
| this.isTestShuttingDown = isTestShuttingDown; |
| this.commandStateHandoff = new AtomicReference<>(); |
| } |
| |
| @Override |
| public BlazeCommandResult exec(CommandEnvironment env, OptionsParsingResult options) { |
| CommandState commandState = new CommandState( |
| env, options.getOptions(WaitOptions.class).expectInterruption, isTestShuttingDown); |
| commandStateHandoff.getAndSet(null).set(commandState); |
| return BlazeCommandResult.exitCode(commandState.waitForExitCodeFromTest()); |
| } |
| |
| @Override |
| public void editOptions(OptionsParser optionsParser) {} |
| |
| /** |
| * Runs an instance of this command on the given executor, waits for it to start and returns a |
| * CommandState which can be used to control and assert on the status of that command. |
| */ |
| public CommandState runIn( |
| ExecutorService executor, BlazeCommandDispatcher dispatcher, boolean expectInterruption) |
| throws InterruptedException, ExecutionException { |
| SettableFuture<CommandState> newHandoff = SettableFuture.create(); |
| if (!commandStateHandoff.compareAndSet(null, newHandoff)) { |
| throw new AssertionError("Another command is already starting at this time?!"); |
| } |
| @SuppressWarnings("unused") // static analysis wants us to check future return values |
| Future<?> ignoredCommandResult = |
| executor.submit( |
| new RunCommandThroughDispatcher(dispatcher, newHandoff, expectInterruption)); |
| return newHandoff.get(); |
| } |
| } |
| |
| /** Callable to run the above command on a different thread. */ |
| private static final class RunCommandThroughDispatcher implements Callable<Integer> { |
| private final BlazeCommandDispatcher dispatcher; |
| private final SettableFuture<CommandState> commandStateHandoff; |
| private final boolean expectInterruption; |
| |
| public RunCommandThroughDispatcher( |
| BlazeCommandDispatcher dispatcher, SettableFuture<CommandState> commandStateHandoff, |
| boolean expectInterruption) { |
| this.dispatcher = dispatcher; |
| this.commandStateHandoff = commandStateHandoff; |
| this.expectInterruption = expectInterruption; |
| } |
| |
| @Override |
| public Integer call() throws Exception { |
| int result; |
| try { |
| result = dispatcher.exec( |
| ImmutableList.of( |
| "snooze", |
| expectInterruption ? "--expect_interruption" : "--noexpect_interruption"), |
| "CommandInterruptionTest", |
| OutErr.SYSTEM_OUT_ERR).getExitCode().getNumericExitCode(); |
| } catch (Exception throwable) { |
| if (commandStateHandoff.isDone()) { |
| commandStateHandoff.get().completeWithFailure(throwable); |
| } else { |
| commandStateHandoff.setException( |
| new IllegalStateException( |
| "The command failed with an exception before WaitForCompletionCommand started.", |
| throwable)); |
| } |
| throw throwable; |
| } |
| |
| if (commandStateHandoff.isDone()) { |
| commandStateHandoff.get().completeWithExitCode(result); |
| } else { |
| commandStateHandoff.setException( |
| new IllegalStateException( |
| "The command failed with exit code " |
| + result |
| + " before WaitForCompletionCommand started.")); |
| } |
| return result; |
| } |
| } |
| |
| /** |
| * A remote control allowing the test to control and assert on the WaitForCompletionCommand. |
| */ |
| private static final class CommandState { |
| private final SettableFuture<Integer> result; |
| private final CommandEnvironment commandEnvironment; |
| private final Thread thread; |
| private final BlockingQueue<ExitCode> exitCodeQueue; |
| private final AtomicBoolean isTestShuttingDown; |
| private boolean expectInterruption; |
| private final CyclicBarrier barrier; |
| |
| private static final ExitCode SENTINEL = |
| ExitCode.createInfrastructureFailure(-1, "GO TO THE BARRIER"); |
| |
| public CommandState( |
| CommandEnvironment commandEnvironment, boolean expectInterruption, |
| AtomicBoolean isTestShuttingDown) { |
| this.result = SettableFuture.create(); |
| this.commandEnvironment = commandEnvironment; |
| this.thread = Thread.currentThread(); |
| this.exitCodeQueue = new ArrayBlockingQueue<ExitCode>(1); |
| this.isTestShuttingDown = isTestShuttingDown; |
| this.expectInterruption = expectInterruption; |
| this.barrier = new CyclicBarrier(2); |
| } |
| |
| // command side |
| |
| /** |
| * Marks the Future associated with this CommandState completed with the given exit code, then |
| * waits at the barrier for the test thread to catch up. |
| */ |
| private void completeWithExitCode(int exitCode) { |
| result.set(exitCode); |
| if (!isTestShuttingDown.get()) { |
| // Wait at the barrier for the test to assert on status, unless the test is shutting down. |
| try { |
| barrier.await(); |
| } catch (InterruptedException | BrokenBarrierException ex) { |
| // this is fine, we're only doing this for the test thread's benefit anyway |
| } |
| } |
| } |
| |
| /** |
| * Marks the Future associated with this CommandState as having failed with the given exit code, |
| * then waits at the barrier for the test thread to catch up. |
| */ |
| private void completeWithFailure(Throwable throwable) { |
| result.setException(throwable); |
| if (!isTestShuttingDown.get()) { |
| // Wait at the barrier for the test to assert on status, unless the test is shutting down. |
| try { |
| barrier.await(); |
| } catch (InterruptedException | BrokenBarrierException ex) { |
| // this is fine, we're only doing this for the test thread's benefit anyway |
| } |
| } |
| } |
| |
| /** |
| * Waits for an exit code to come from the test, either INTERRUPTED via thread interruption, or |
| * a test-specified exit code via requestExitWith(). If expectInterruption was set, |
| * a single interruption will be ignored. |
| */ |
| private ExitCode waitForExitCodeFromTest() { |
| while (true) { |
| ExitCode exitCode = null; |
| try { |
| exitCode = exitCodeQueue.take(); |
| if (Thread.interrupted()) { |
| // the interruption and the exit code delivery may have come in simultaneously, which |
| // may result in a successful return from the queue with interrupted() set. |
| throw new InterruptedException(); |
| } |
| } catch (InterruptedException ex) { |
| if (!expectInterruption || isTestShuttingDown.get()) { |
| // This is not an expected interruption (possibly because the test is shutting down and |
| // it's the executor's please stop interruption) so give up. |
| return ExitCode.INTERRUPTED; |
| } |
| // Otherwise, that was an expected interruption, so return to looking for exit codes. |
| // But we only expect one, so the next one will be fatal. |
| expectInterruption = false; |
| // We fall through the catch here in case we received an interruption and an exit code at |
| // the same time. |
| } |
| |
| if (SENTINEL.equals(exitCode)) { |
| // The test just wants us to go wait at the barrier for an assertion. |
| try { |
| barrier.await(); |
| } catch (InterruptedException | BrokenBarrierException impossible) { |
| // This should not happen in normal use, but if it does, exit gracefully so |
| // BlazeCommandDispatcher has a chance to clean up. Use the SENTINEL value to avoid |
| // accidentally passing any tests that might have been looking for INTERRUPTED. |
| return SENTINEL; |
| } |
| continue; |
| } else if (exitCode != null) { |
| return exitCode; |
| } |
| } |
| } |
| |
| // test side |
| |
| /** Gets the ModuleEnvironment modules will see when executing this command. */ |
| public BlazeModule.ModuleEnvironment getModuleEnvironment() { |
| return commandEnvironment.getBlazeModuleEnvironment(); |
| } |
| |
| /** Sends an exit code to the command, which will then return with it if it is still running. */ |
| public void requestExitWith(ExitCode exitCode) { |
| exitCodeQueue.offer(exitCode); |
| } |
| |
| /** Sends an interrupt directly to the command's thread. */ |
| public void interrupt() { |
| thread.interrupt(); |
| } |
| |
| /** Waits for the command to reach a stopping point to check if it has finished or not. */ |
| private void synchronizeWithCommand() throws InterruptedException, BrokenBarrierException { |
| // If the future is already done, no need to wait at the barrier - we already know the state. |
| if (result.isDone()) { |
| // But if the command thread is waiting on the barrier, tell it to stop doing so. |
| barrier.reset(); |
| return; |
| } |
| // Offer the sentinel to the queue - if the command is still waiting and it sees the sentinel, |
| // it will go to the barrier. |
| exitCodeQueue.offer(SENTINEL); |
| // Then wait for the command to finish processing. |
| barrier.await(); |
| } |
| |
| /** Asserts that the command finished and returned the given ExitCode. */ |
| public void assertFinishedWith(ExitCode exitCode) |
| throws InterruptedException, ExecutionException, BrokenBarrierException { |
| synchronizeWithCommand(); |
| assertWithMessage("The command should have been finished, but it was not.") |
| .that(result.isDone()).isTrue(); |
| assertThat(Futures.getDone(result)).isEqualTo(exitCode.getNumericExitCode()); |
| } |
| |
| /** Asserts that the command has not finished yet. */ |
| public void assertNotFinishedYet() |
| throws InterruptedException, ExecutionException, BrokenBarrierException { |
| synchronizeWithCommand(); |
| if (result.isDone()) { |
| try { |
| throw new AssertionError( |
| "The command should not have been finished, but it finished with exit code " |
| + result.get()); |
| } catch (Throwable ex) { |
| throw new AssertionError("The command should not have been finished, but it threw", ex); |
| } |
| } |
| } |
| |
| /** Asserts that both commands were executed on the same thread. */ |
| public void assertOnSameThreadAs(CommandState other) { |
| assertThat(thread).isSameInstanceAs(other.thread); |
| } |
| } |
| |
| private ExecutorService executor; |
| private AtomicBoolean isTestShuttingDown; |
| private BlazeCommandDispatcher dispatcher; |
| private WaitForCompletionCommand snooze; |
| |
| @Before |
| public void setUp() throws Exception { |
| executor = Executors.newSingleThreadExecutor(); |
| Scratch scratch = new Scratch(); |
| isTestShuttingDown = new AtomicBoolean(false); |
| String productName = TestConstants.PRODUCT_NAME; |
| ServerDirectories serverDirectories = |
| new ServerDirectories( |
| scratch.dir("install"), scratch.dir("output"), scratch.dir("user_root")); |
| BlazeRuntime runtime = |
| new BlazeRuntime.Builder() |
| .setFileSystem(scratch.getFileSystem()) |
| .setProductName(productName) |
| .setServerDirectories(serverDirectories) |
| .setStartupOptionsProvider( |
| OptionsParser.builder().optionsClasses(BlazeServerStartupOptions.class).build()) |
| .addBlazeModule( |
| new BlazeModule() { |
| @Override |
| public void initializeRuleClasses(ConfiguredRuleClassProvider.Builder builder) { |
| // Can't create a Skylark environment without a tools repository! |
| builder.setToolsRepository(TestConstants.TOOLS_REPOSITORY); |
| // Can't create a defaults package without the base options in there! |
| builder.addConfigurationOptions(CoreOptions.class); |
| builder.addConfigurationOptions(TestConfiguration.TestOptions.class); |
| } |
| }) |
| .addBlazeModule( |
| new BlazeModule() { |
| @Override |
| public BuildOptions getDefaultBuildOptions(BlazeRuntime runtime) { |
| return BuildOptions.getDefaultBuildOptionsForFragments( |
| runtime.getRuleClassProvider().getConfigurationOptions()); |
| } |
| }) |
| .build(); |
| snooze = new WaitForCompletionCommand(isTestShuttingDown); |
| dispatcher = new BlazeCommandDispatcher(runtime, snooze); |
| BlazeDirectories blazeDirectories = |
| new BlazeDirectories( |
| serverDirectories, |
| scratch.dir("workspace"), |
| /* defaultSystemJavabase= */ null, |
| productName); |
| runtime.initWorkspace(blazeDirectories, /* binTools= */ null); |
| } |
| |
| @After |
| public void tearDown() throws Exception { |
| isTestShuttingDown.set(true); |
| executor.shutdownNow(); |
| executor.awaitTermination(TestUtils.WAIT_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS); |
| } |
| |
| // These tests are basically testing the functionality of the dummy command. |
| @Test |
| public void sendingExitCodeToTestCommandResultsInExitWithThatStatus() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ false); |
| command.requestExitWith(ExitCode.SUCCESS); |
| command.assertFinishedWith(ExitCode.SUCCESS); |
| } |
| |
| @Test |
| public void interruptingTestCommandMakesItExitWithInterruptedStatus() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ false); |
| command.interrupt(); |
| command.assertFinishedWith(ExitCode.INTERRUPTED); |
| } |
| |
| @Test |
| public void commandIgnoresFirstInterruptionWhenExpectingInterruption() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ true); |
| command.interrupt(); |
| command.assertNotFinishedYet(); |
| command.requestExitWith(ExitCode.SUCCESS); |
| command.assertFinishedWith(ExitCode.SUCCESS); |
| } |
| |
| @Test |
| public void commandExitsWithInterruptedAfterInterruptionCountExceeded() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ true); |
| command.interrupt(); |
| command.assertNotFinishedYet(); |
| command.interrupt(); |
| command.assertFinishedWith(ExitCode.INTERRUPTED); |
| } |
| |
| // These tests get into the meat of actual abrupt exits. |
| @Test |
| public void exitForbidsNullException() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ false); |
| try { |
| command.getModuleEnvironment().exit(null); |
| throw new AssertionError("It shouldn't be allowed to pass null to exit()!"); |
| } catch (NullPointerException expected) { |
| // Good! |
| } |
| command.assertNotFinishedYet(); |
| command.requestExitWith(ExitCode.SUCCESS); |
| } |
| |
| @Test |
| public void exitForbidsNullExitCode() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ false); |
| try { |
| command.getModuleEnvironment().exit(new AbruptExitException("", null)); |
| throw new AssertionError( |
| "It shouldn't be allowed to pass an AbruptExitException with null ExitCode to exit()!"); |
| } catch (NullPointerException expected) { |
| // Good! |
| } |
| command.assertNotFinishedYet(); |
| command.requestExitWith(ExitCode.SUCCESS); |
| } |
| |
| @Test |
| public void callingExitOnceInterruptsAndOverridesExitCode() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ false); |
| command.getModuleEnvironment().exit(new AbruptExitException("", ExitCode.NO_TESTS_FOUND)); |
| command.assertFinishedWith(ExitCode.NO_TESTS_FOUND); |
| } |
| |
| @Test |
| public void callingExitSecondTimeNeitherInterruptsNorReOverridesExitCode() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ true); |
| command.getModuleEnvironment().exit(new AbruptExitException("", ExitCode.NO_TESTS_FOUND)); |
| command.assertNotFinishedYet(); |
| command.getModuleEnvironment().exit(new AbruptExitException("", ExitCode.ANALYSIS_FAILURE)); |
| command.assertNotFinishedYet(); |
| command.requestExitWith(ExitCode.SUCCESS); |
| command.assertFinishedWith(ExitCode.NO_TESTS_FOUND); |
| } |
| |
| @Test |
| public void abruptExitCodesDontOverrideInfrastructureFailures() throws Exception { |
| CommandState command = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ true); |
| command.getModuleEnvironment().exit(new AbruptExitException("", ExitCode.NO_TESTS_FOUND)); |
| command.assertNotFinishedYet(); |
| command.requestExitWith(ExitCode.BLAZE_INTERNAL_ERROR); |
| command.assertFinishedWith(ExitCode.BLAZE_INTERNAL_ERROR); |
| } |
| |
| @Test |
| public void callingExitAfterCommandCompletesDoesNothing() throws Exception { |
| CommandState firstCommand = snooze.runIn(executor, dispatcher, /*expectInterruption=*/ false); |
| firstCommand.requestExitWith(ExitCode.SUCCESS); |
| firstCommand.assertFinishedWith(ExitCode.SUCCESS); |
| CommandState newCommandOnSameThread = |
| snooze.runIn(executor, dispatcher, /*expectInterruption=*/ false); |
| firstCommand.assertOnSameThreadAs(newCommandOnSameThread); |
| firstCommand.getModuleEnvironment().exit(new AbruptExitException("", ExitCode.RUN_FAILURE)); |
| newCommandOnSameThread.assertNotFinishedYet(); |
| newCommandOnSameThread.requestExitWith(ExitCode.SUCCESS); |
| } |
| } |