blob: 2a9600aacea45f9a274365b21ceefd7d0cb16e68 [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.remote;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import com.google.devtools.build.lib.testutil.TestThread;
import com.google.devtools.build.lib.testutil.TestUtils;
import com.google.testing.junit.testparameterinjector.TestParameter;
import com.google.testing.junit.testparameterinjector.TestParameterInjector;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Tests for {@link ReaderPreferringReadWriteLock}. */
@RunWith(TestParameterInjector.class)
public class ReaderPreferringReadWriteLockTest {
public enum Group {
READ {
@Override
void lock(ReaderPreferringReadWriteLock lock) throws InterruptedException {
lock.lockReadInterruptibly();
}
@Override
void unlock(ReaderPreferringReadWriteLock lock) {
lock.unlockRead();
}
@Override
Group otherGroup() {
return WRITE;
}
},
WRITE {
@Override
void lock(ReaderPreferringReadWriteLock lock) throws InterruptedException {
lock.lockWriteInterruptibly();
}
@Override
void unlock(ReaderPreferringReadWriteLock lock) {
lock.unlockWrite();
}
@Override
Group otherGroup() {
return READ;
}
};
abstract void lock(ReaderPreferringReadWriteLock lock) throws InterruptedException;
abstract void unlock(ReaderPreferringReadWriteLock lock);
abstract Group otherGroup();
}
@Test
public void lockRead_multipleReaders_succeeds() throws Exception {
var lock = new ReaderPreferringReadWriteLock();
for (int i = 0; i < 10; i++) {
lock.lockReadInterruptibly();
}
for (int i = 0; i < 10; i++) {
lock.unlockRead();
}
// The lock has been fully released: a writer can acquire it.
lock.lockWriteInterruptibly();
lock.unlockWrite();
}
@Test
public void unlock_lockNotHeld_throws(@TestParameter Group group) {
var lock = new ReaderPreferringReadWriteLock();
assertThrows(IllegalMonitorStateException.class, () -> group.unlock(lock));
}
@Test
public void unlock_lockHeldByOtherGroup_throws(@TestParameter Group group) throws Exception {
var lock = new ReaderPreferringReadWriteLock();
group.lock(lock);
assertThrows(IllegalMonitorStateException.class, () -> group.otherGroup().unlock(lock));
group.unlock(lock);
}
@Test
public void lock_lockHeldByOtherGroup_blocksUntilReleased(@TestParameter Group group)
throws Exception {
var otherGroup = group.otherGroup();
var lock = new ReaderPreferringReadWriteLock();
group.lock(lock);
var otherGroupAcquisitions = new AtomicInteger();
var otherGroupThread =
new TestThread(
() -> {
otherGroup.lock(lock);
otherGroupAcquisitions.incrementAndGet();
otherGroup.unlock(lock);
});
otherGroupThread.start();
waitUntilState(otherGroupThread, Thread.State.WAITING);
assertThat(otherGroupAcquisitions.get()).isEqualTo(0);
group.unlock(lock);
otherGroupThread.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
assertThat(otherGroupAcquisitions.get()).isEqualTo(1);
// group can acquire again after otherGroup has released the lock.
group.lock(lock);
group.unlock(lock);
}
@Test
public void lockWrite_lockHeldByWriter_blocksUntilReleased() throws Exception {
var lock = new ReaderPreferringReadWriteLock();
lock.lockWriteInterruptibly();
var otherWriterAcquisitions = new AtomicInteger();
var otherWriter =
new TestThread(
() -> {
lock.lockWriteInterruptibly();
otherWriterAcquisitions.incrementAndGet();
lock.unlockWrite();
});
otherWriter.start();
waitUntilState(otherWriter, Thread.State.WAITING);
assertThat(otherWriterAcquisitions.get()).isEqualTo(0);
lock.unlockWrite();
otherWriter.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
assertThat(otherWriterAcquisitions.get()).isEqualTo(1);
}
@Test
public void lockRead_writerWaiting_isAdmitted() throws Exception {
var lock = new ReaderPreferringReadWriteLock();
lock.lockReadInterruptibly();
var writerAcquisitions = new AtomicInteger();
var writer =
new TestThread(
() -> {
lock.lockWriteInterruptibly();
writerAcquisitions.incrementAndGet();
lock.unlockWrite();
});
writer.start();
waitUntilState(writer, Thread.State.WAITING);
// A new reader is admitted while the writer waits.
lock.lockReadInterruptibly();
assertThat(writerAcquisitions.get()).isEqualTo(0);
// The writer only acquires the lock once all readers have released it.
lock.unlockRead();
assertThat(writerAcquisitions.get()).isEqualTo(0);
lock.unlockRead();
writer.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
assertThat(writerAcquisitions.get()).isEqualTo(1);
// Both groups can acquire the lock without blocking after the writer has released it.
lock.lockReadInterruptibly();
lock.unlockRead();
lock.lockWriteInterruptibly();
lock.unlockWrite();
}
@Test
public void unlockRead_newReaderBeforeNotification_writerIsNotStranded() throws Exception {
var lock = new ReaderPreferringReadWriteLock();
lock.lockReadInterruptibly();
var writerAcquisitions = new AtomicInteger();
var writer =
new TestThread(
() -> {
lock.lockWriteInterruptibly();
writerAcquisitions.incrementAndGet();
lock.unlockWrite();
});
writer.start();
waitUntilState(writer, Thread.State.WAITING);
var releasingReader = new TestThread(lock::unlockRead);
synchronized (lock) {
releasingReader.start();
// Pause the last reader after it releases the lock but before it notifies the writer.
waitUntilState(releasingReader, Thread.State.BLOCKED);
lock.lockReadInterruptibly();
}
releasingReader.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
// The notified writer must record itself as waiting again while this new reader holds the lock.
waitUntilState(writer, Thread.State.WAITING);
assertThat(writerAcquisitions.get()).isEqualTo(0);
lock.unlockRead();
writer.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
assertThat(writerAcquisitions.get()).isEqualTo(1);
}
@Test
public void lock_interruptedWhileBlocked_throwsAndKeepsLockUsable(@TestParameter Group group)
throws Exception {
var otherGroup = group.otherGroup();
var lock = new ReaderPreferringReadWriteLock();
group.lock(lock);
var interrupted = new CountDownLatch(1);
var otherGroupThread =
new TestThread(
() -> {
try {
otherGroup.lock(lock);
} catch (InterruptedException e) {
interrupted.countDown();
}
});
otherGroupThread.start();
waitUntilState(otherGroupThread, Thread.State.WAITING);
otherGroupThread.interrupt();
otherGroupThread.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
assertThat(interrupted.getCount()).isEqualTo(0);
// The interrupted acquisition must not have taken or corrupted the lock: group still holds it
// and after releasing it, both groups can acquire it.
group.unlock(lock);
otherGroup.lock(lock);
otherGroup.unlock(lock);
group.lock(lock);
group.unlock(lock);
}
@Test
@SuppressWarnings("ThreadPriorityCheck")
public void lockRead_interruptedWriter_doesNotEnterMonitor() throws Exception {
var lock = new ReaderPreferringReadWriteLock();
lock.lockReadInterruptibly();
var writer =
new TestThread(
() -> assertThrows(InterruptedException.class, lock::lockWriteInterruptibly));
writer.start();
waitUntilState(writer, Thread.State.WAITING);
writer.interrupt();
writer.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
lock.unlockRead();
// Cancelling the only writer must leave subsequent readers independent of the monitor.
var reader =
new TestThread(
() -> {
for (int i = 0; i < 3; i++) {
lock.lockReadInterruptibly();
lock.unlockRead();
}
});
try {
synchronized (lock) {
reader.start();
Thread.State state;
while ((state = reader.getState()) != Thread.State.TERMINATED) {
assertThat(state).isNotEqualTo(Thread.State.BLOCKED);
Thread.yield();
}
}
} finally {
reader.joinAndAssertState(TestUtils.WAIT_TIMEOUT_MILLISECONDS);
}
}
@Test
public void lock_interruptedBeforeAcquisition_throwsAndKeepsLockUsable(@TestParameter Group group)
throws Exception {
var lock = new ReaderPreferringReadWriteLock();
try {
Thread.currentThread().interrupt();
assertThrows(InterruptedException.class, () -> group.lock(lock));
} finally {
// Avoid leaking the interrupt into other tests if the assertion fails.
Thread.interrupted();
}
// The interrupted acquisition must not have taken or corrupted the lock.
group.otherGroup().lock(lock);
group.otherGroup().unlock(lock);
}
@Test
public void lockAndUnlock_concurrentThreads_readersAndWritersNeverOverlap() {
var lock = new ReaderPreferringReadWriteLock();
var writers = new AtomicInteger();
var readers = new AtomicInteger();
var violations = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 16; i++) {
int seed = i;
executor.execute(
() -> {
try {
var random = new Random(seed);
for (int j = 0; j < 20_000; j++) {
if (random.nextInt(4) == 0) {
lock.lockWriteInterruptibly();
try {
// The writer is exclusive: no other writer and no reader may be active.
if (writers.incrementAndGet() != 1 || readers.get() != 0) {
violations.incrementAndGet();
}
writers.decrementAndGet();
} finally {
lock.unlockWrite();
}
} else {
lock.lockReadInterruptibly();
try {
readers.incrementAndGet();
if (writers.get() != 0) {
violations.incrementAndGet();
}
readers.decrementAndGet();
} finally {
lock.unlockRead();
}
}
}
} catch (InterruptedException e) {
// Interruptions are not expected.
violations.incrementAndGet();
}
});
}
}
assertThat(violations.get()).isEqualTo(0);
assertThat(readers.get()).isEqualTo(0);
assertThat(writers.get()).isEqualTo(0);
}
@SuppressWarnings("ThreadPriorityCheck")
private static void waitUntilState(Thread thread, Thread.State expectedState) {
Thread.State state;
while ((state = thread.getState()) != expectedState) {
assertThat(state).isNotEqualTo(Thread.State.TERMINATED);
Thread.yield();
}
}
}