/*
* Copyright 2013-present Facebook, Inc.
*
* 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.facebook.buck.testrunner;
import com.facebook.buck.util.concurrent.MostExecutors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.runner.Description;
import org.junit.runner.Runner;
import org.junit.runner.notification.RunNotifier;
/**
* {@link Runner} that composes a {@link Runner} that enforces a default timeout when running a
* test.
*/
class DelegateRunnerWithTimeout extends Runner {
/**
* {@link ExecutorService} on which all tests run by this {@link Runner} are executed.
*
* <p>In Robolectric, the {@code ShadowLooper.resetThreadLoopers()} asserts that the current
* thread is the same as the thread on which the {@code ShadowLooper} class was loaded. Therefore,
* to preserve the behavior of the {@code org.robolectric.RobolectricTestRunner}, we use an {@link
* ExecutorService} to create and run the test on. This has the unfortunate side effect of
* creating one thread per runner, but JUnit ensures that they're all called serially, so the
* overall effect is that of having only a single thread.
*
* <p>We use a {@link ThreadLocal} so that if a test spawns more tests that create their own
* runners we don't deadlock.
*/
private static final ThreadLocal<ExecutorService> executor =
new ThreadLocal<ExecutorService>() {
@Override
protected ExecutorService initialValue() {
return MostExecutors.newSingleThreadExecutor(
DelegateRunnerWithTimeout.class.getSimpleName());
}
};
private final Runner delegate;
private final long defaultTestTimeoutMillis;
DelegateRunnerWithTimeout(Runner delegate, long defaultTestTimeoutMillis) {
if (defaultTestTimeoutMillis <= 0) {
throw new IllegalArgumentException(
String.format(
"defaultTestTimeoutMillis must be greater than zero but was: %s.",
defaultTestTimeoutMillis));
}
this.delegate = delegate;
this.defaultTestTimeoutMillis = defaultTestTimeoutMillis;
}
/** @return the description from the original {@link Runner} wrapped by this {@link Runner}. */
@Override
public Description getDescription() {
return delegate.getDescription();
}
/**
* Runs the tests for this runner, but wraps the specified {@code notifier} with a {@link
* DelegateRunNotifier} that intercepts calls to the original {@code notifier}. The {@link
* DelegateRunNotifier} is what enables us to impose our default timeout.
*/
@Override
public void run(RunNotifier notifier) {
final DelegateRunNotifier wrapper =
new DelegateRunNotifier(delegate, notifier, defaultTestTimeoutMillis);
if (wrapper.hasJunitTimeout(getDescription())) {
runWithoutBuckManagedTimeout(wrapper);
} else {
runWithBuckManagedTimeout(wrapper);
}
}
private void runWithBuckManagedTimeout(final DelegateRunNotifier wrapper) {
final Semaphore completionSemaphore = new Semaphore(1);
// Acquire the one permit so later tryAcquire attempts lock
// until they either time out or this permit is released by
// DeletgateRunNotifier.onTestRunFinished()
try {
completionSemaphore.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
shutdown();
return;
}
final AtomicBoolean testsCompleted = new AtomicBoolean(false);
// We run the Runner in an Executor so that we can tear it down if we need to.
executor
.get()
.submit(
() -> {
try {
delegate.run(wrapper);
} finally {
if (!wrapper.hasTestThatExceededTimeout()) {
testsCompleted.set(true);
}
completionSemaphore.release();
}
});
// We poll the Executor to see if the Runner is complete. In the event that a test has exceeded
// the default timeout, we cancel the Runner to protect against the case where the test hangs
// forever.
while (true) {
if (testsCompleted.get()) {
// Normal termination: hooray!
return;
}
if (wrapper.hasTestThatExceededTimeout()) {
// The test results that have been reported to the RunNotifier should still be output, but
// there may be tests that did not have a chance to run. Unfortunately, we have no way to
// tell the Runner to cancel only the runaway test.
shutdown();
return;
}
// Tests are still running, so wait and try again.
try {
if (completionSemaphore.tryAcquire(250L, TimeUnit.MILLISECONDS)) {
completionSemaphore.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
shutdown();
return;
}
}
}
private void runWithoutBuckManagedTimeout(final DelegateRunNotifier wrapper) {
delegate.run(wrapper);
}
private void shutdown() {
executor.get().shutdownNow();
}
}