From 0e6ad2804d9170cef54c5f92bd3e64918182cc89 Mon Sep 17 00:00:00 2001 From: Andrea Aime Date: Thu, 12 Feb 2026 19:14:16 +0100 Subject: [PATCH] Don't hold locks forever, use a configurable timeout instead --- .../org/geowebcache/locks/LockProvider.java | 6 + .../geowebcache/locks/MemoryLockProvider.java | 19 +- .../geowebcache/locks/NIOLockProvider.java | 204 +++++++------ .../locks/MemoryLockProviderTest.java | 268 ++++++++++++++++++ .../locks/NIOLockProviderTest.java | 140 +++++++++ 5 files changed, 523 insertions(+), 114 deletions(-) create mode 100644 geowebcache/core/src/test/java/org/geowebcache/locks/MemoryLockProviderTest.java create mode 100644 geowebcache/core/src/test/java/org/geowebcache/locks/NIOLockProviderTest.java diff --git a/geowebcache/core/src/main/java/org/geowebcache/locks/LockProvider.java b/geowebcache/core/src/main/java/org/geowebcache/locks/LockProvider.java index deb701fdb..4e63b4645 100644 --- a/geowebcache/core/src/main/java/org/geowebcache/locks/LockProvider.java +++ b/geowebcache/core/src/main/java/org/geowebcache/locks/LockProvider.java @@ -22,6 +22,12 @@ */ public interface LockProvider { + /** + * The amount of time, in seconds, to wait for a lock to be released before giving up and throwing an exception. + * Default is 2 minutes. + */ + int GWC_LOCK_TIMEOUT = Integer.parseInt(System.getProperty("GWC_LOCK_TIMEOUT", String.valueOf(2 * 60))); + /** Acquires a exclusive lock on the specified key */ public Lock getLock(String lockKey) throws GeoWebCacheException; diff --git a/geowebcache/core/src/main/java/org/geowebcache/locks/MemoryLockProvider.java b/geowebcache/core/src/main/java/org/geowebcache/locks/MemoryLockProvider.java index fa5269501..b52d0cb14 100644 --- a/geowebcache/core/src/main/java/org/geowebcache/locks/MemoryLockProvider.java +++ b/geowebcache/core/src/main/java/org/geowebcache/locks/MemoryLockProvider.java @@ -14,6 +14,7 @@ package org.geowebcache.locks; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; @@ -69,7 +70,15 @@ public Lock getLock(String lockKey) { return internalLockAndCounter; }); - lockAndCounter.lock.lock(); + try { + if (!lockAndCounter.lock.tryLock(GWC_LOCK_TIMEOUT, TimeUnit.SECONDS)) { + // Throwing an exception prevents the thread from hanging indefinitely + throw new RuntimeException(String.format("Lock acquisition timeout for key [%s].", lockKey)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while trying to acquire lock for key " + lockKey, e); + } if (LOGGER.isLoggable(Level.FINE)) LOGGER.fine("Acquired lock key " + lockKey); @@ -88,10 +97,8 @@ public void release() { // Attempt to remove lock if no other thread is waiting for it if (lockAndCounter.counter.decrementAndGet() == 0) { - // Try to remove the lock, but we have to check the count AGAIN inside of - // "compute" - // so that we know it hasn't been incremented since the if-statement above - // was evaluated + // Try to remove the lock, but we have to check the count AGAIN inside of "compute" so that we + // know it hasn't been incremented since the if-statement above was evaluated lockAndCounters.compute(lockKey, (key, existingLockAndCounter) -> { if (existingLockAndCounter == null || existingLockAndCounter.counter.get() == 0) { return null; @@ -111,7 +118,7 @@ public void release() { * remove it during a release. */ private static class LockAndCounter { - private final java.util.concurrent.locks.Lock lock = new ReentrantLock(); + private final ReentrantLock lock = new ReentrantLock(); // The count of threads holding or waiting for this lock private final AtomicInteger counter = new AtomicInteger(0); diff --git a/geowebcache/core/src/main/java/org/geowebcache/locks/NIOLockProvider.java b/geowebcache/core/src/main/java/org/geowebcache/locks/NIOLockProvider.java index 12b1db391..6e8d86004 100644 --- a/geowebcache/core/src/main/java/org/geowebcache/locks/NIOLockProvider.java +++ b/geowebcache/core/src/main/java/org/geowebcache/locks/NIOLockProvider.java @@ -34,6 +34,7 @@ */ public class NIOLockProvider implements LockProvider { + public static final int DEFAULT_WAIT_BEFORE_RETRY = 20; public static Logger LOGGER = Logging.getLogger(NIOLockProvider.class.getName()); private final String root; @@ -41,8 +42,7 @@ public class NIOLockProvider implements LockProvider { /** The wait to occur in case the lock cannot be acquired */ private final int waitBeforeRetry; - /** max lock attempts */ - private final int maxLockAttempts; + private final int timeoutSeconds; MemoryLockProvider memoryProvider = new MemoryLockProvider(); @@ -50,149 +50,137 @@ public NIOLockProvider(DefaultStorageFinder storageFinder) throws ConfigurationE this(storageFinder.getDefaultPath()); } - public NIOLockProvider(DefaultStorageFinder storageFinder, int waitBeforeRetry, int maxLockAttempts) + public NIOLockProvider(DefaultStorageFinder storageFinder, int waitBeforeRetry, int timeoutSeconds) throws ConfigurationException { this.root = storageFinder.getDefaultPath(); this.waitBeforeRetry = waitBeforeRetry; - this.maxLockAttempts = maxLockAttempts; + this.timeoutSeconds = timeoutSeconds; } - public NIOLockProvider(String root) throws ConfigurationException { + public NIOLockProvider(String root) { this.root = root; - this.waitBeforeRetry = 20; - this.maxLockAttempts = 120 * 1000 / waitBeforeRetry; + this.waitBeforeRetry = DEFAULT_WAIT_BEFORE_RETRY; + this.timeoutSeconds = GWC_LOCK_TIMEOUT; + } + + public NIOLockProvider(String root, int timeoutSeconds) { + this.root = root; + this.waitBeforeRetry = DEFAULT_WAIT_BEFORE_RETRY; + this.timeoutSeconds = timeoutSeconds; } @Override - @SuppressWarnings({"PMD.CloseResource", "PMD.UseTryWithResources"}) + @SuppressWarnings({"PMD.CloseResource"}) // complex but seemingly correct resource handling public LockProvider.Lock getLock(final String lockKey) throws GeoWebCacheException { - File file = null; // first off, synchronize among threads in the same jvm (the nio locks won't lock // threads in the same JVM) final LockProvider.Lock memoryLock = memoryProvider.getLock(lockKey); - // then synch up between different processes + final File file = getFile(lockKey); + + // Track these to ensure cleanup on failure + FileOutputStream currFos = null; + FileLock currLock = null; + + if (LOGGER.isLoggable(Level.FINE)) + LOGGER.fine("Mapped lock key " + lockKey + " to lock file " + file + ". Attempting to lock on it."); try { - file = getFile(lockKey); - FileOutputStream currFos = null; - FileLock currLock = null; - try { - // try to lock - int count = 0; - while (currLock == null && count < maxLockAttempts) { - // the file output stream can also fail to be acquired due to the - // other nodes deleting the file - try { - currFos = new FileOutputStream(file); + long lockTimeoutMs = timeoutSeconds * 1000L; + long startTime = System.currentTimeMillis(); - currLock = currFos.getChannel().lock(); - } catch (OverlappingFileLockException | IOException e) { + while (currLock == null && (System.currentTimeMillis() - startTime) < lockTimeoutMs) { + try { + currFos = new FileOutputStream(file); + currLock = currFos.getChannel().tryLock(); + + if (currLock == null) { IOUtils.closeQuietly(currFos); - try { - Thread.sleep(waitBeforeRetry); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - // ok, moving on - } + Thread.sleep(waitBeforeRetry); } - count++; - } - - // verify we managed to get the FS lock - if (count >= maxLockAttempts) { - throw new GeoWebCacheException( - "Failed to get a lock on key " + lockKey + " after " + count + " attempts"); - } - - if (LOGGER.isLoggable(Level.FINE)) { - LOGGER.fine("Lock " - + lockKey - + " acquired by thread " - + Thread.currentThread().getName() - + " on file " - + file); + } catch (OverlappingFileLockException | IOException | InterruptedException e) { + IOUtils.closeQuietly(currFos); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + break; + } + Thread.sleep(waitBeforeRetry); } + } - // store the results in a final variable for the inner class to use - final FileOutputStream fos = currFos; - final FileLock lock = currLock; - - // nullify so that we don't close them, the locking occurred as expected - currFos = null; - currLock = null; + if (currLock == null) { + throw new IllegalStateException("Failed to get lock on " + lockKey + " after " + lockTimeoutMs + "ms"); + } - final File lockFile = file; - return new LockProvider.Lock() { + if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.fine("Lock " + + lockKey + + " acquired by thread " + + Thread.currentThread().getId() + + " on file " + + file); + } - boolean released; + final FileOutputStream finalFos = currFos; + final FileLock finalLock = currLock; - @Override - public void release() throws GeoWebCacheException { - if (released) { - return; - } + return new LockProvider.Lock() { + boolean released; - try { - released = true; - if (!lock.isValid()) { - // do not crap out, locks usage in GWC is only there to prevent - // duplication of work - if (LOGGER.isLoggable(Level.FINE)) { - LOGGER.fine( - "Lock key " - + lockKey - + " for releasing lock is unkonwn, it means " - + "this lock was never acquired, or was released twice. " - + "Current thread is: " - + Thread.currentThread().getName() - + ". " - + "Are you running two GWC instances in the same JVM using NIO locks? " - + "This case is not supported and will generate exactly this error message"); - return; - } + @Override + public void release() throws GeoWebCacheException { + if (released) return; + try { + released = true; + if (finalLock.isValid()) { + finalLock.release(); + IOUtils.closeQuietly(finalFos); + file.delete(); // Proper place for deletion + + if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.fine(String.format( + "Lock %s mapped onto %s released by thread %d", + lockKey, file, Thread.currentThread().getId())); } - try { - lock.release(); - IOUtils.closeQuietly(fos); - lockFile.delete(); - - if (LOGGER.isLoggable(Level.FINE)) { - LOGGER.fine("Lock " - + lockKey - + " on file " - + lockFile - + " released by thread " - + Thread.currentThread().getName()); - } - } catch (IOException e) { - throw new GeoWebCacheException( - "Failure while trying to release lock for key " + lockKey, e); + } else { + // do not crap out, locks usage is only there to prevent duplication + // of work + if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.fine(String.format( + "Lock key %s for releasing lock is unknown, it means this lock was never" + + " acquired, or was released twice. Current thread is: %d. Are you" + + " running two instances in the same JVM using NIO locks? This case is" + + " not supported and will generate exactly this error message", + lockKey, Thread.currentThread().getId())); } - } finally { - memoryLock.release(); } + + } catch (IOException e) { + throw new IllegalStateException("Failure releasing lock " + lockKey, e); + } finally { + memoryLock.release(); } - }; - } finally { + } + }; + } catch (Exception e) { + // If we get here, acquisition failed or timed out + if (currLock != null) { try { - if (currLock != null) { - currLock.release(); - } - IOUtils.closeQuietly(currFos); - file.delete(); - } finally { - memoryLock.release(); + currLock.release(); + } catch (IOException ignored) { } } - } catch (IOException e) { - throw new GeoWebCacheException("Failure while trying to get lock for key " + lockKey, e); + IOUtils.closeQuietly(currFos); + memoryLock.release(); // Must release memory lock on failure + throw (e instanceof RuntimeException) ? (RuntimeException) e : new IllegalStateException(e); } + // Note: No finally block deleting the file here, it's done in the returned lock } private File getFile(String lockKey) { File locks = new File(root, "lockfiles"); locks.mkdirs(); - String sha1 = DigestUtils.sha1Hex(lockKey); + // cryptographically strong and has a chance of collision around 10^-59 + String sha1 = DigestUtils.sha256Hex(lockKey); return new File(locks, sha1 + ".lck"); } } diff --git a/geowebcache/core/src/test/java/org/geowebcache/locks/MemoryLockProviderTest.java b/geowebcache/core/src/test/java/org/geowebcache/locks/MemoryLockProviderTest.java new file mode 100644 index 000000000..09ca009ed --- /dev/null +++ b/geowebcache/core/src/test/java/org/geowebcache/locks/MemoryLockProviderTest.java @@ -0,0 +1,268 @@ +/* (c) 2026 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geowebcache.locks; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.geowebcache.GeoWebCacheException; +import org.junit.After; +import org.junit.Test; + +/** + * Tests the memory lock, including its ability to be reentrant. The keys for meta-tile and tile caching are different, + * so we don't really need it to handle nested locks on the same key, but we want to make sure it doesn't break if we + * ever had two threads in the same JVM using resources in a nested way (just for extra safety, it should not happen). + */ +public class MemoryLockProviderTest { + + /** Long timeout held to avoid CI jitter; tests should pass much faster than this */ + private static final int LONG_TIMEOUT_SECONDS = 5; + + /** A small window for "should not happen yet" checks */ + private static final int SHORT_WINDOW_MILLIS = 200; + + /** Do not leak interrupt status to other tests */ + @After + public void clearInterruptFlag() { + Thread.interrupted(); + } + + private static void awaitForLockCleanup(MemoryLockProvider provider, String key) { + // Be tolerant in CI; await returns immediately once condition is true + await().atMost(2, SECONDS).until(() -> !provider.lockAndCounters.containsKey(key)); + } + + private static void awaitLatch(CountDownLatch latch, String message) throws InterruptedException { + assertTrue(message, latch.await(LONG_TIMEOUT_SECONDS, SECONDS)); + } + + private static void assertLatchNotReleasedYet(CountDownLatch latch, String message) throws InterruptedException { + assertFalse(message, latch.await(SHORT_WINDOW_MILLIS, MILLISECONDS)); + } + + private static void awaitFuture(Future future) throws Exception { + future.get(LONG_TIMEOUT_SECONDS, SECONDS); + } + + private static void shutdownNow(ExecutorService exec) { + exec.shutdownNow(); + } + + /** Runs {@code body} with a fixed thread pool and guarantees shutdown + exception propagation. */ + private static void withExecutor(int threads, ThrowingConsumer body) throws Exception { + ExecutorService exec = Executors.newFixedThreadPool(threads); + try { + body.accept(exec); + } finally { + shutdownNow(exec); + } + } + + /** + * Submits a task that acquires {@code key}, signals {@code hasLock}, then waits for {@code allowRelease} before + * releasing. + */ + private static Future submitLockHolder( + ExecutorService exec, + MemoryLockProvider provider, + String key, + CountDownLatch hasLock, + CountDownLatch allowRelease, + String threadNameForMessages) { + + return exec.submit(() -> { + LockProvider.Lock lock = provider.getLock(key); + try { + hasLock.countDown(); + assertTrue( + threadNameForMessages + ": release signal not received", + allowRelease.await(LONG_TIMEOUT_SECONDS, SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail(threadNameForMessages + " interrupted"); + } finally { + try { + lock.release(); + } catch (GeoWebCacheException e) { + fail(threadNameForMessages + ": failed to release lock: " + e.getMessage()); + } + } + }); + } + + /** Submits a task that waits for {@code startSignal}, then acquires {@code key} and signals {@code entered}. */ + private static Future submitLockAcquirer( + ExecutorService exec, + MemoryLockProvider provider, + String key, + CountDownLatch startSignal, + CountDownLatch entered, + String threadNameForMessages) { + + return exec.submit(() -> { + try { + awaitLatch(startSignal, threadNameForMessages + ": start signal not received"); + LockProvider.Lock lock = provider.getLock(key); + try { + entered.countDown(); + } finally { + lock.release(); + } + } catch (InterruptedException | GeoWebCacheException e) { + Thread.currentThread().interrupt(); + fail(threadNameForMessages + " interrupted"); + } + }); + } + + @FunctionalInterface + private interface ThrowingConsumer { + void accept(T value) throws Exception; + } + + @Test + public void testAcquireReleaseRemovesEntry() throws GeoWebCacheException { + MemoryLockProvider provider = new MemoryLockProvider(); + assertTrue(provider.lockAndCounters.isEmpty()); + + LockProvider.Lock lock = provider.getLock("k"); + assertNotNull(lock); + assertTrue(provider.lockAndCounters.containsKey("k")); + + lock.release(); + awaitForLockCleanup(provider, "k"); + } + + @Test + public void testReleaseIsIdempotent() throws GeoWebCacheException { + MemoryLockProvider provider = new MemoryLockProvider(); + + LockProvider.Lock lock = provider.getLock("k"); + lock.release(); + lock.release(); // must be a no-op + + awaitForLockCleanup(provider, "k"); + } + + @Test + public void testNestedLocksSameThread() throws GeoWebCacheException { + MemoryLockProvider provider = new MemoryLockProvider(); + + // Nested (re-entrant) acquires on the same key + LockProvider.Lock l1 = provider.getLock("k"); + LockProvider.Lock l2 = provider.getLock("k"); + LockProvider.Lock l3 = provider.getLock("k"); + + assertTrue("Entry should exist while nested locks are held", provider.lockAndCounters.containsKey("k")); + + // Release in a different order than acquisition + l2.release(); + assertTrue("Entry should still exist after partial release", provider.lockAndCounters.containsKey("k")); + + l1.release(); + assertTrue("Entry should still exist while one nested lock is held", provider.lockAndCounters.containsKey("k")); + + l3.release(); + awaitForLockCleanup(provider, "k"); + } + + @Test + public void testMutualExclusionSameKey() throws Exception { + MemoryLockProvider provider = new MemoryLockProvider(); + + withExecutor(2, exec -> { + CountDownLatch t1HasLock = new CountDownLatch(1); + CountDownLatch allowT1ToRelease = new CountDownLatch(1); + CountDownLatch t2EnteredCriticalSection = new CountDownLatch(1); + + Future t1 = submitLockHolder(exec, provider, "k", t1HasLock, allowT1ToRelease, "t1"); + Future t2 = submitLockAcquirer(exec, provider, "k", t1HasLock, t2EnteredCriticalSection, "t2"); + + // While t1 holds the lock, t2 must NOT enter. + assertLatchNotReleasedYet( + t2EnteredCriticalSection, "t2 should not enter critical section while t1 holds the lock"); + + // Once t1 releases, t2 must enter. + allowT1ToRelease.countDown(); + awaitLatch(t2EnteredCriticalSection, "t2 should enter critical section after t1 releases"); + + // Propagate worker exceptions + awaitFuture(t1); + awaitFuture(t2); + }); + + awaitForLockCleanup(provider, "k"); + } + + @Test + public void testDifferentKeysDoNotBlockEachOther() throws Exception { + MemoryLockProvider provider = new MemoryLockProvider(); + + withExecutor(2, exec -> { + CountDownLatch t1HasA = new CountDownLatch(1); + CountDownLatch allowT1ToReleaseA = new CountDownLatch(1); + CountDownLatch t2AcquiredB = new CountDownLatch(1); + + Future t1 = submitLockHolder(exec, provider, "A", t1HasA, allowT1ToReleaseA, "t1"); + Future t2 = submitLockAcquirer(exec, provider, "B", t1HasA, t2AcquiredB, "t2"); + + // B should be acquirable even while A is held. + awaitLatch(t2AcquiredB, "B should be acquirable even while A is held"); + + // Cleanup + allowT1ToReleaseA.countDown(); + awaitFuture(t1); + awaitFuture(t2); + }); + + await().atMost(2, SECONDS).until(() -> provider.lockAndCounters.isEmpty()); + } + + @Test + public void testInterruptedWhileAcquiringThrowsAndPreservesInterruptFlag() throws Exception { + MemoryLockProvider provider = new MemoryLockProvider(); + + ExecutorService exec = Executors.newSingleThreadExecutor(); + try { + CountDownLatch holderHasLock = new CountDownLatch(1); + CountDownLatch allowHolderToRelease = new CountDownLatch(1); + + Future holder = submitLockHolder(exec, provider, "k", holderHasLock, allowHolderToRelease, "holder"); + + awaitLatch(holderHasLock, "holder did not acquire lock in time"); + + // Interrupt *this* thread before trying to acquire -> tryLock should throw InterruptedException, + // MemoryLockProvider should re-interrupt the thread and wrap into RuntimeException + Thread.currentThread().interrupt(); + try { + provider.getLock("k"); + fail("Expected RuntimeException due to interruption"); + } catch (RuntimeException expected) { + // ok + } + + assertTrue( + "Interrupt flag should be preserved", Thread.currentThread().isInterrupted()); + + // IMPORTANT: clear interrupt before cleanup that blocks + Thread.interrupted(); + + allowHolderToRelease.countDown(); + awaitFuture(holder); + } finally { + shutdownNow(exec); + } + } +} diff --git a/geowebcache/core/src/test/java/org/geowebcache/locks/NIOLockProviderTest.java b/geowebcache/core/src/test/java/org/geowebcache/locks/NIOLockProviderTest.java new file mode 100644 index 000000000..086072c2b --- /dev/null +++ b/geowebcache/core/src/test/java/org/geowebcache/locks/NIOLockProviderTest.java @@ -0,0 +1,140 @@ +/* (c) 2026 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geowebcache.locks; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.geowebcache.GeoWebCacheException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * The file lock provider is actually not reentrant (not possible with Java) but the keys for meta-tile caching are + * different, so we don't really need it to handle nested locks on the same key. + */ +public class NIOLockProviderTest { + + /** Small timeout for testing failure cases quickly */ + private static final int TEST_TIMEOUT_SECONDS = 1; + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private NIOLockProvider provider; + private String root; + + @Before + public void setUp() throws Exception { + root = tempFolder.newFolder("lockRoot").getCanonicalPath(); + provider = new NIOLockProvider(root, TEST_TIMEOUT_SECONDS); + } + + @Test + public void testAcquireAndReleaseDeletesFile() throws Exception { + String key = "test-key"; + LockProvider.Lock lock = provider.getLock(key); + + File lockFile = getLockFile(key); + assertTrue("Lock file should exist on disk", lockFile.exists()); + + lock.release(); + + // Use awaitility to handle potential OS delay in file deletion + await().atMost(2, SECONDS).until(() -> !lockFile.exists()); + } + + @Test + public void testLockTimeoutThrowsException() throws Exception { + String key = "timeout-key"; + // Create a second provider instance pointing to the same root + // This ensures they don't share the same MemoryLockProvider + NIOLockProvider provider2 = new NIOLockProvider(root, TEST_TIMEOUT_SECONDS); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + CountDownLatch threadAHold = new CountDownLatch(1); + CountDownLatch releaseThreadA = new CountDownLatch(1); + + executor.submit(() -> { + try { + LockProvider.Lock lock = provider.getLock(key); + threadAHold.countDown(); + try { + releaseThreadA.await(5, SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + try { + lock.release(); + } catch (GeoWebCacheException e) { + fail("Failed to release lock: " + e.getMessage()); + } + } + } catch (GeoWebCacheException e) { + throw new RuntimeException(e); + } + }); + + assertTrue("Thread A failed to acquire lock", threadAHold.await(2, SECONDS)); + + long start = System.currentTimeMillis(); + try { + // Use the SECOND provider; it will be blocked by the file on disk + provider2.getLock(key); + fail("Should have thrown IllegalStateException due to timeout"); + } catch (IllegalStateException e) { + long duration = System.currentTimeMillis() - start; + assertTrue("Timeout was too fast: " + duration, duration >= 1000); + assertTrue(e.getMessage().contains("ms")); + } finally { + releaseThreadA.countDown(); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + public void testInterruptionDuringAcquisition() throws Exception { + String key = "interrupt-key"; + + // Mock a situation where the lock is already held + LockProvider.Lock firstLock = provider.getLock(key); + + Thread testThread = new Thread(() -> { + try { + provider.getLock(key); + fail("Should have been interrupted"); + } catch (Exception e) { + // Expected + } + }); + + testThread.start(); + Thread.sleep(200); // Let it enter the loop + testThread.interrupt(); + + testThread.join(2000); + assertFalse("Thread should have terminated after interruption", testThread.isAlive()); + + firstLock.release(); + } + + private File getLockFile(String key) { + // This mimics the internal logic of FileLockProvider to verify disk state + String hash = org.apache.commons.codec.digest.DigestUtils.sha256Hex(key); + return new File(new File(root, "lockfiles"), hash + ".lck"); + } +}