blob: 65380fb752e34c49a45613877df35c67d17c1f35 [file] [log] [blame]
// 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);
}
}
}