blob: 961809eb2af1ff04bb7f3ae625daec7ab3adc6a3 [file]
// Copyright 2026 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.concurrent;
import static com.google.common.truth.Truth.assertThat;
import static com.google.devtools.build.lib.testutil.TestUtils.WAIT_TIMEOUT_MILLISECONDS;
import static com.google.devtools.build.lib.testutil.TestUtils.WAIT_TIMEOUT_SECONDS;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.Assert.assertThrows;
import com.google.devtools.build.lib.testutil.TestThread;
import com.google.testing.junit.testparameterinjector.TestParameter;
import com.google.testing.junit.testparameterinjector.TestParameterInjector;
import java.io.IOException;
import java.util.ArrayList;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Tests for {@link CancellableTask}. */
@RunWith(TestParameterInjector.class)
public final class CancellableTaskTest {
/** The cancellation entry point under test, which must not affect the cancellation semantics. */
private enum CancelVariant {
INTERRUPTIBLE {
@Override
void cancel(CancellableTask<?> task) throws InterruptedException {
task.cancelAndAwait();
}
},
REQUEST_THEN_AWAIT {
@Override
void cancel(CancellableTask<?> task) throws InterruptedException {
task.requestCancellation();
task.awaitCompletion();
}
};
abstract void cancel(CancellableTask<?> task) throws InterruptedException;
}
@Test
public void runTwice_throws() throws Exception {
var task = new CancellableTask<>(() -> {});
assertThat(task.runIfNotCancelled()).isTrue();
assertThrows(IllegalStateException.class, task::runIfNotCancelled);
}
@Test
public void cancelBeforeRun_preventsTaskFromRunning(@TestParameter CancelVariant variant)
throws Exception {
var ran = new AtomicBoolean();
var task = new CancellableTask<>(() -> ran.set(true));
variant.cancel(task);
assertThat(task.runIfNotCancelled()).isFalse();
assertThat(ran.get()).isFalse();
}
@Test
public void cancelTwiceBeforeRun_returnsWithoutBlocking(@TestParameter CancelVariant variant)
throws Exception {
var task = new CancellableTask<>(() -> {});
variant.cancel(task);
variant.cancel(task);
}
@Test
public void cancelDuringRun_interruptsAndAwaitsTask(@TestParameter CancelVariant variant)
throws Exception {
var taskStarted = new Semaphore(0);
var taskInterrupted = new Semaphore(0);
var taskMayFinish = new Semaphore(0);
var cancellationReturned = new Semaphore(0);
var task =
new CancellableTask<>(
() -> {
taskStarted.release();
try {
taskMayFinish.acquire();
} catch (InterruptedException e) {
taskInterrupted.release();
taskMayFinish.acquireUninterruptibly();
}
});
var runner = new TestThread(() -> assertThat(task.runIfNotCancelled()).isTrue());
runner.start();
assertThat(taskStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
var canceller =
new TestThread(
() -> {
variant.cancel(task);
cancellationReturned.release();
});
canceller.start();
try {
assertThat(cancellationReturned.tryAcquire(100, MILLISECONDS)).isFalse();
assertThat(taskInterrupted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
} finally {
taskMayFinish.release();
}
assertThat(cancellationReturned.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
}
@Test
public void cancelAfterRun_returnsWithoutBlocking(@TestParameter CancelVariant variant)
throws Exception {
var task = new CancellableTask<>(() -> {});
assertThat(task.runIfNotCancelled()).isTrue();
variant.cancel(task);
// Repeated cancellation is allowed and must not block either.
variant.cancel(task);
}
@Test
public void completion_runsOnceAfterBody() throws Exception {
var events = new ArrayList<String>();
var task = new CancellableTask<>(() -> events.add("body"), () -> events.add("completion"));
assertThat(task.runIfNotCancelled()).isTrue();
assertThat(events).containsExactly("body", "completion").inOrder();
// Cancellation after completion must not run the completion action again.
task.cancelAndAwait();
assertThat(events).containsExactly("body", "completion").inOrder();
}
@Test
public void completion_runsWhenBodyThrows() {
var completionRuns = new AtomicInteger();
var task =
new CancellableTask<>(
() -> {
throw new IOException("task failed");
},
completionRuns::incrementAndGet);
assertThrows(IOException.class, task::runIfNotCancelled);
assertThat(completionRuns.get()).isEqualTo(1);
}
@Test
public void cancelBeforeRun_runsCompletionExactlyOnce(@TestParameter CancelVariant variant)
throws Exception {
var completionRuns = new AtomicInteger();
var task = new CancellableTask<>(() -> {}, completionRuns::incrementAndGet);
variant.cancel(task);
assertThat(completionRuns.get()).isEqualTo(1);
// Neither a repeated cancellation nor the prevented run runs the completion action again.
variant.cancel(task);
assertThat(task.runIfNotCancelled()).isFalse();
assertThat(completionRuns.get()).isEqualTo(1);
}
@Test
public void cancelBeforeRun_concurrentCancellerAwaitsCompletion(
@TestParameter CancelVariant variant) throws Exception {
var completionStarted = new Semaphore(0);
var completionMayFinish = new Semaphore(0);
var task =
new CancellableTask<>(
() -> {},
() -> {
completionStarted.release();
completionMayFinish.acquireUninterruptibly();
});
var winner = new TestThread(() -> variant.cancel(task));
winner.start();
assertThat(completionStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
var loserReturned = new Semaphore(0);
var loser =
new TestThread(
() -> {
variant.cancel(task);
loserReturned.release();
});
loser.start();
try {
// The losing canceler must not return while the winner's completion action is running.
assertThat(loserReturned.tryAcquire(100, MILLISECONDS)).isFalse();
} finally {
completionMayFinish.release();
}
assertThat(loserReturned.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
winner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
loser.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
}
@Test
public void cancelDuringCompletion_awaitsWithoutInterruptingCompletion(
@TestParameter CancelVariant variant) throws Exception {
var completionStarted = new Semaphore(0);
var completionMayFinish = new Semaphore(0);
var completionWasInterrupted = new AtomicBoolean();
var task =
new CancellableTask<>(
() -> {},
() -> {
completionStarted.release();
try {
completionMayFinish.acquire();
} catch (InterruptedException e) {
completionWasInterrupted.set(true);
}
});
var runner = new TestThread(() -> assertThat(task.runIfNotCancelled()).isTrue());
runner.start();
assertThat(completionStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
var cancellationReturned = new Semaphore(0);
var canceller =
new TestThread(
() -> {
variant.cancel(task);
cancellationReturned.release();
});
canceller.start();
try {
// Completion is part of the terminal lifecycle, but cancellation only interrupts the body.
assertThat(cancellationReturned.tryAcquire(100, MILLISECONDS)).isFalse();
assertThat(completionWasInterrupted.get()).isFalse();
} finally {
completionMayFinish.release();
}
assertThat(cancellationReturned.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
assertThat(completionWasInterrupted.get()).isFalse();
}
@Test
public void runLosingToCancelBeforeStart_awaitsCompletion(@TestParameter CancelVariant variant)
throws Exception {
var completionStarted = new Semaphore(0);
var completionMayFinish = new Semaphore(0);
var task =
new CancellableTask<>(
() -> {},
() -> {
completionStarted.release();
completionMayFinish.acquireUninterruptibly();
});
var canceller = new TestThread(() -> variant.cancel(task));
canceller.start();
assertThat(completionStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
var runReturned = new Semaphore(0);
var ran = new AtomicBoolean(true);
var runner =
new TestThread(
() -> {
ran.set(task.runIfNotCancelled());
runReturned.release();
});
runner.start();
try {
assertThat(runReturned.tryAcquire(100, MILLISECONDS)).isFalse();
} finally {
completionMayFinish.release();
}
assertThat(runReturned.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
assertThat(ran.get()).isFalse();
}
@Test
public void taskThrows_propagatesExceptionAndUnblocksCanceller(
@TestParameter CancelVariant variant) throws Exception {
var taskStarted = new Semaphore(0);
var taskMayThrow = new Semaphore(0);
var task =
new CancellableTask<IOException>(
() -> {
taskStarted.release();
taskMayThrow.acquireUninterruptibly();
throw new IOException("task failed");
});
var thrown = new AtomicReference<IOException>();
var runner =
new TestThread(() -> thrown.set(assertThrows(IOException.class, task::runIfNotCancelled)));
runner.start();
assertThat(taskStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
var cancellationReturned = new Semaphore(0);
var canceller =
new TestThread(
() -> {
variant.cancel(task);
cancellationReturned.release();
});
canceller.start();
try {
assertThat(cancellationReturned.tryAcquire(100, MILLISECONDS)).isFalse();
} finally {
taskMayThrow.release();
}
// A task that leaves its body by throwing must still unblock cancelers.
assertThat(cancellationReturned.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
assertThat(thrown.get()).hasMessageThat().isEqualTo("task failed");
}
@Test
public void cancelFromTaskBody_throws(@TestParameter CancelVariant variant) throws Exception {
var taskRef = new AtomicReference<CancellableTask<Exception>>();
var task =
new CancellableTask<Exception>(
() -> assertThrows(IllegalStateException.class, () -> variant.cancel(taskRef.get())));
taskRef.set(task);
assertThat(task.runIfNotCancelled()).isTrue();
}
@Test
public void cancelFromCompletionAfterBody_throws(@TestParameter CancelVariant variant)
throws Exception {
var taskRef = new AtomicReference<CancellableTask<Exception>>();
var task =
new CancellableTask<Exception>(
() -> {},
() -> assertThrows(IllegalStateException.class, () -> variant.cancel(taskRef.get())));
taskRef.set(task);
var runner = new TestThread(() -> assertThat(task.runIfNotCancelled()).isTrue());
runner.start();
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
}
@Test
public void cancelFromCancelBeforeStartCompletion_throws(@TestParameter CancelVariant variant)
throws Exception {
var taskRef = new AtomicReference<CancellableTask<Exception>>();
var task =
new CancellableTask<Exception>(
() -> {},
() -> assertThrows(IllegalStateException.class, () -> variant.cancel(taskRef.get())));
taskRef.set(task);
var canceller = new TestThread(() -> variant.cancel(task));
canceller.start();
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
assertThat(task.runIfNotCancelled()).isFalse();
}
@Test
public void runFromCancelBeforeStartCompletion_throws() throws Exception {
var taskRef = new AtomicReference<CancellableTask<Exception>>();
var task =
new CancellableTask<Exception>(
() -> {},
() ->
assertThrows(IllegalStateException.class, () -> taskRef.get().runIfNotCancelled()));
taskRef.set(task);
var canceller = new TestThread(task::cancelAndAwait);
canceller.start();
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
}
@Test
public void completionThrowsAfterBody_stillSignalsTerminalState(
@TestParameter CancelVariant variant) throws Exception {
var task =
new CancellableTask<>(
() -> {},
() -> {
throw new IllegalStateException("completion failed");
});
assertThat(assertThrows(IllegalStateException.class, task::runIfNotCancelled))
.hasMessageThat()
.isEqualTo("completion failed");
assertLaterCancellationReturns(task, variant);
}
@Test
public void completionThrowsAfterCancelBeforeStart_stillSignalsTerminalState(
@TestParameter CancelVariant variant) throws Exception {
var task =
new CancellableTask<>(
() -> {},
() -> {
throw new IllegalStateException("completion failed");
});
assertThat(assertThrows(IllegalStateException.class, () -> variant.cancel(task)))
.hasMessageThat()
.isEqualTo("completion failed");
assertLaterCancellationReturns(task, variant);
assertThat(task.runIfNotCancelled()).isFalse();
}
private static void assertLaterCancellationReturns(CancellableTask<?> task, CancelVariant variant)
throws Exception {
var cancellationReturned = new Semaphore(0);
var canceller =
new TestThread(
() -> {
variant.cancel(task);
cancellationReturned.release();
});
canceller.start();
assertThat(cancellationReturned.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
}
@Test
public void requestCancellation_beforeRun_preventsStartAndRunsCompletion() throws Exception {
var ran = new AtomicBoolean();
var completionRuns = new AtomicInteger();
var task = new CancellableTask<>(() -> ran.set(true), completionRuns::incrementAndGet);
task.requestCancellation();
assertThat(completionRuns.get()).isEqualTo(1);
assertThat(task.runIfNotCancelled()).isFalse();
assertThat(ran.get()).isFalse();
assertThat(completionRuns.get()).isEqualTo(1);
}
@Test
public void requestCancellation_duringRun_interruptsWithoutAwaiting() throws Exception {
var taskStarted = new Semaphore(0);
var taskInterrupted = new Semaphore(0);
var taskMayFinish = new Semaphore(0);
var task =
new CancellableTask<>(
() -> {
taskStarted.release();
try {
taskMayFinish.acquire();
} catch (InterruptedException e) {
taskInterrupted.release();
taskMayFinish.acquireUninterruptibly();
}
});
var runner = new TestThread(() -> assertThat(task.runIfNotCancelled()).isTrue());
runner.start();
assertThat(taskStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
// Returns while the body is still running: it only finishes after the release below.
task.requestCancellation();
assertThat(taskInterrupted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
taskMayFinish.release();
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
}
@Test
public void requestCancellation_thenAwaitCompletion_doesNotInterruptTaskAgain() throws Exception {
var taskStarted = new Semaphore(0);
var cleanupStarted = new Semaphore(0);
var cleanupMayFinish = new Semaphore(0);
var interruptionCount = new AtomicInteger();
var task =
new CancellableTask<>(
() -> {
taskStarted.release();
try {
new Semaphore(0).acquire();
} catch (InterruptedException e) {
interruptionCount.incrementAndGet();
}
cleanupStarted.release();
try {
cleanupMayFinish.acquire();
} catch (InterruptedException e) {
interruptionCount.incrementAndGet();
}
});
var runner = new TestThread(() -> assertThat(task.runIfNotCancelled()).isTrue());
runner.start();
assertThat(taskStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
task.requestCancellation();
assertThat(cleanupStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
var waiter = new TestThread(task::awaitCompletion);
waiter.start();
try {
// Awaiting a previously requested cancellation must not send another interrupt that could
// abort the task's cleanup.
assertThat(waiter.isAlive()).isTrue();
assertThat(interruptionCount.get()).isEqualTo(1);
} finally {
cleanupMayFinish.release();
}
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
waiter.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
assertThat(interruptionCount.get()).isEqualTo(1);
}
@Test
public void cancelAndAwait_interruptedWhileAwaiting_retryInterruptsAgainAndAwaits()
throws Exception {
var taskStarted = new Semaphore(0);
var taskMayFinish = new Semaphore(0);
var taskInterruptions = new Semaphore(0);
var interruptionCount = new AtomicInteger();
var task =
new CancellableTask<>(
() -> {
taskStarted.release();
while (true) {
try {
taskMayFinish.acquire();
return;
} catch (InterruptedException e) {
interruptionCount.incrementAndGet();
taskInterruptions.release();
}
}
});
var runner = new TestThread(() -> assertThat(task.runIfNotCancelled()).isTrue());
runner.start();
assertThat(taskStarted.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
var cancellerThrew = new Semaphore(0);
var cancellationReturned = new Semaphore(0);
var canceller =
new TestThread(
() -> {
assertThrows(InterruptedException.class, task::cancelAndAwait);
cancellerThrew.release();
// A retry interrupts the still-running task again and awaits quiescence.
task.cancelAndAwait();
cancellationReturned.release();
});
canceller.start();
assertThat(taskInterruptions.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
canceller.interrupt();
assertThat(cancellerThrew.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
assertThat(taskInterruptions.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
try {
assertThat(cancellationReturned.tryAcquire(100, MILLISECONDS)).isFalse();
assertThat(interruptionCount.get()).isEqualTo(2);
} finally {
taskMayFinish.release();
}
assertThat(cancellationReturned.tryAcquire(WAIT_TIMEOUT_SECONDS, SECONDS)).isTrue();
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
assertThat(interruptionCount.get()).isEqualTo(2);
}
@Test
public void runAfterCancel_whileInterrupted_restoresInterruptBit() throws Exception {
var task = new CancellableTask<>(() -> {});
task.cancelAndAwait();
Thread.currentThread().interrupt();
assertThat(task.runIfNotCancelled()).isFalse();
assertThat(Thread.interrupted()).isTrue();
}
@Test
public void concurrentRunAndCancel_exactlyOneClaimWins() throws Exception {
for (int i = 0; i < 1000; i++) {
var bodyRuns = new AtomicInteger();
var bodyRunning = new AtomicBoolean();
var completionRuns = new AtomicInteger();
var task =
new CancellableTask<>(
() -> {
bodyRunning.set(true);
bodyRuns.incrementAndGet();
bodyRunning.set(false);
},
completionRuns::incrementAndGet);
var barrier = new CyclicBarrier(3);
var ran = new AtomicBoolean();
var runner =
new TestThread(
() -> {
barrier.await();
ran.set(task.runIfNotCancelled());
});
var cancellers = new TestThread[2];
for (int c = 0; c < cancellers.length; c++) {
cancellers[c] =
new TestThread(
() -> {
barrier.await();
task.cancelAndAwait();
// A normal return from cancellation guarantees that the body is not running
// and that the completion action has finished.
assertThat(bodyRunning.get()).isFalse();
assertThat(completionRuns.get()).isEqualTo(1);
});
}
// Rotate which thread arrives at the barrier last: that thread trips the barrier and
// proceeds without parking while the others are still being woken, so it tends to win the
// claim. Rotation makes both outcomes of the race common.
var startOrder =
switch (i % 3) {
case 0 -> new TestThread[] {runner, cancellers[0], cancellers[1]};
case 1 -> new TestThread[] {cancellers[0], cancellers[1], runner};
default -> new TestThread[] {cancellers[0], runner, cancellers[1]};
};
for (var thread : startOrder) {
thread.start();
}
runner.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
for (var canceller : cancellers) {
canceller.joinAndAssertState(WAIT_TIMEOUT_MILLISECONDS);
}
// The claim is atomic: either the task ran to completion exactly once and no canceler
// prevented it, or cancellation prevented it from running.
assertThat(ran.get()).isEqualTo(bodyRuns.get() == 1);
assertThat(bodyRuns.get()).isAtMost(1);
assertThat(completionRuns.get()).isEqualTo(1);
}
}
}