| // Copyright 2022 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.testutil; |
| |
| import static com.google.common.base.MoreObjects.firstNonNull; |
| |
| import com.google.common.flogger.GoogleLogger; |
| import com.google.devtools.build.lib.bugreport.BugReporter; |
| import com.google.devtools.build.lib.bugreport.Crash; |
| import com.google.devtools.build.lib.bugreport.CrashContext; |
| import java.lang.Thread.UncaughtExceptionHandler; |
| import java.util.List; |
| import java.util.concurrent.atomic.AtomicReference; |
| import org.junit.rules.TestRule; |
| import org.junit.runner.Description; |
| import org.junit.runners.model.Statement; |
| |
| /** |
| * {@link TestRule} that interrupts the main test thread when an unexpected exception in an async |
| * thread is encountered. |
| * |
| * <p>Designed for use in tests that would otherwise hang indefinitely in the event of a bug. In |
| * blaze, unexpected exceptions are typically handled by calling {@link Runtime#halt} to terminate |
| * the JVM. In Java tests, however, this does not work because: |
| * |
| * <ul> |
| * <li>If {@link com.google.devtools.build.lib.runtime.BlazeRuntime} is not in scope for the test, |
| * there may not be a relevant {@link UncaughtExceptionHandler} installed. |
| * <li>Calling {@link Runtime#halt} in a Java test is not allowed and leads to a {@link |
| * SecurityException}. |
| * </ul> |
| * |
| * <p>Consider a class with the following method: |
| * |
| * <pre>{@code |
| * public Future<Void> doSomethingAsync() { |
| * SettableFuture<Void> future = SettableFuture.create(); |
| * executor.execute(() -> { |
| * ... |
| * Preconditions.checkState(someCondition); |
| * future.set(null); |
| * }); |
| * return future; |
| * } |
| * }</pre> |
| * |
| * and a corresponding unit test: |
| * |
| * <pre>{@code |
| * @Test |
| * public void testSomethingAsync() throws Exception { |
| * Future<Void> future = underTest.doSomethingAsync(); |
| * future.get(); |
| * } |
| * }</pre> |
| * |
| * If the call to {@code Preconditions.checkState} fails, the test hangs indefinitely. Diagnosing |
| * the issue would require waiting for the test to time out and then analyzing the test log to find |
| * the uncaught exception. Instead, using {@code TestInterruptingBugReporter} will immediately |
| * interrupt the main test thread and display the uncaught exception as the test's failure cause. |
| * |
| * <p>{@code TestInteruptingBugReporter} can also be used as a {@link BugReporter} if the system |
| * under test is designed to accept one: |
| * |
| * <pre>{@code |
| * public Future<Void> doSomethingAsync(BugReporter bugReporter) { |
| * SettableFuture<Void> future = SettableFuture.create(); |
| * executor.execute(() -> { |
| * ... |
| * if (!someCondition) { |
| * bugReporter.sendBugReport(new IllegalStateException("someCondition was false"); |
| * } |
| * future.set(null); |
| * }); |
| * return future; |
| * } |
| * }</pre> |
| * |
| * Example usage: |
| * |
| * <pre>{@code |
| * @Rule public final TestInterruptingBugReporter bugReporter = new TestInterruptingBugReporter(); |
| * |
| * @Test |
| * public void testSomethingAsync() throws Exception { |
| * Future<Void> future = underTest.doSomethingAsync(bugReporter); |
| * future.get(); |
| * } |
| * }</pre> |
| */ |
| public final class TestInterruptingBugReporter |
| implements BugReporter, UncaughtExceptionHandler, TestRule { |
| |
| private static final GoogleLogger logger = GoogleLogger.forEnclosingClass(); |
| |
| // This is the main test thread so long as this class is being used as documented (instantiated |
| // as a field in the test class). |
| private final Thread testThread = Thread.currentThread(); |
| |
| private final AtomicReference<Throwable> bug = new AtomicReference<>(); |
| |
| @Override |
| public Statement apply(Statement base, Description description) { |
| return new Statement() { |
| @Override |
| public void evaluate() throws Throwable { |
| UncaughtExceptionHandler originalHandler = Thread.getDefaultUncaughtExceptionHandler(); |
| Thread.setDefaultUncaughtExceptionHandler(TestInterruptingBugReporter.this); |
| try { |
| base.evaluate(); |
| } catch (InterruptedException e) { |
| throw firstNonNull(bug.get(), e); |
| } finally { |
| Thread.setDefaultUncaughtExceptionHandler(originalHandler); |
| } |
| } |
| }; |
| } |
| |
| @Override |
| public void sendBugReport(Throwable exception, List<String> args, String... values) { |
| handle(exception, "call to sendBugReport", Thread.currentThread()); |
| } |
| |
| @Override |
| public void sendNonFatalBugReport(Exception exception) { |
| handle(exception, "call to sendNonFatalBugReport", Thread.currentThread()); |
| } |
| |
| @Override |
| public void handleCrash(Crash crash, CrashContext ctx) { |
| handle(crash.getThrowable(), "call to handleCrash", Thread.currentThread()); |
| } |
| |
| @Override |
| public void uncaughtException(Thread thread, Throwable exception) { |
| handle(exception, "uncaught exception", thread); |
| } |
| |
| private synchronized void handle(Throwable exception, String context, Thread thread) { |
| if (thread.equals(testThread)) { |
| throw new IllegalStateException(exception); |
| } |
| if (bug.compareAndSet(null, exception)) { |
| logger.atSevere().withCause(exception).log( |
| "Handling %s in thread %s by interrupting the main test thread", |
| context, thread.getName()); |
| testThread.interrupt(); |
| } else { |
| logger.atSevere().withCause(exception).log( |
| "Ignoring %s in thread %s since a previous exception was seen", |
| context, thread.getName()); |
| bug.get().addSuppressed(exception); |
| } |
| } |
| } |