diff --git a/org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/npm/FilesystemPackageCacheManager.java b/org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/npm/FilesystemPackageCacheManager.java index df192cc19..37e55c020 100644 --- a/org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/npm/FilesystemPackageCacheManager.java +++ b/org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/npm/FilesystemPackageCacheManager.java @@ -215,12 +215,31 @@ public class FilesystemPackageCacheManager extends BasePackageCacheManager imple createIniFile(); } else { deleteOldTempDirectories(); + cleanUpCorruptPackages(); } } return null; }); } + /* + Look for .lock files that are not actively held by a process. If found, delete the lock file, and the package + referenced. + */ + protected void cleanUpCorruptPackages() throws IOException { + for (File file : Objects.requireNonNull(cacheFolder.listFiles())) { + if (file.getName().endsWith(".lock")) { + String packageDirectoryName = file.getName().substring(0, file.getName().length() - 5); + File packageDirectory = ManagedFileAccess.file(Utilities.path(cacheFolder, packageDirectoryName)); + if (packageDirectory.exists()) { + Utilities.clearDirectory(packageDirectory.getAbsolutePath()); + packageDirectory.delete(); + } + file.delete(); + } + } + } + private boolean isCacheFolderValid() throws IOException { String iniPath = getPackagesIniPath(); File iniFile = ManagedFileAccess.file(iniPath); diff --git a/org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/npm/FilesystemPackageCacheManagerLocks.java b/org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/npm/FilesystemPackageCacheManagerLocks.java index d674c750e..fd75bde5d 100644 --- a/org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/npm/FilesystemPackageCacheManagerLocks.java +++ b/org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/npm/FilesystemPackageCacheManagerLocks.java @@ -118,6 +118,20 @@ public class FilesystemPackageCacheManagerLocks { if (!lockFile.exists()) { return; } + + // Check if the file is locked by a process. If it is not, it is likely an incomplete package cache install, and + // we should throw an exception. + if (lockFile.isFile()) { + try (FileChannel channel = new RandomAccessFile(lockFile, "rw").getChannel()) { + FileLock fileLock = channel.tryLock(0, Long.MAX_VALUE, true); + if (fileLock != null) { + fileLock.release(); + channel.close(); + throw new IOException("Lock file exists, but is not locked by a process: " + lockFile.getName()); + } + } + } + try (WatchService watchService = FileSystems.getDefault().newWatchService()) { Path dir = lockFile.getParentFile().toPath(); dir.register(watchService, StandardWatchEventKinds.ENTRY_DELETE); diff --git a/org.hl7.fhir.utilities/src/test/java/org/hl7/fhir/utilities/npm/FilesystemPackageManagerTests.java b/org.hl7.fhir.utilities/src/test/java/org/hl7/fhir/utilities/npm/FilesystemPackageManagerTests.java index 96754c2b8..0e135af8d 100644 --- a/org.hl7.fhir.utilities/src/test/java/org/hl7/fhir/utilities/npm/FilesystemPackageManagerTests.java +++ b/org.hl7.fhir.utilities/src/test/java/org/hl7/fhir/utilities/npm/FilesystemPackageManagerTests.java @@ -1,22 +1,30 @@ package org.hl7.fhir.utilities.npm; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; import java.io.File; import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.nio.file.Files; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; import javax.annotation.Nonnull; +import org.hl7.fhir.utilities.Utilities; import org.hl7.fhir.utilities.filesystem.ManagedFileAccess; -import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.EnabledOnOs; @@ -111,6 +119,67 @@ public class FilesystemPackageManagerTests { assertEquals( System.getenv("ProgramData") + "\\.fhir\\packages", folder.getAbsolutePath()); } + @Test + public void testFailureForUnlockedLockFiles() throws IOException, InterruptedException { + String pcmPath = ManagedFileAccess.fromPath(Files.createTempDirectory("fpcm-multithreadingTest")).getAbsolutePath(); + + final FilesystemPackageCacheManager pcm = new FilesystemPackageCacheManager.Builder().withCacheFolder(pcmPath).build(); + + Assertions.assertTrue(pcm.listPackages().isEmpty()); + + //Now sneak in a new lock file and directory: + File lockFile = ManagedFileAccess.file(pcmPath, "example.fhir.uv.myig#1.2.3.lock"); + lockFile.createNewFile(); + File directory = ManagedFileAccess.file(pcmPath, "example.fhir.uv.myig#1.2.3" ); + directory.mkdir(); + + IOException exception = assertThrows(IOException.class, () -> pcm.loadPackageFromCacheOnly("example.fhir.uv.myig", "1.2.3")); + assertThat(exception.getMessage()).contains("Lock file exists, but is not locked by a process"); + } + + @Test + //@EnabledOnOs(OS.LINUX) + public void testCacheCleanupForUnlockedLockFiles() throws IOException, InterruptedException { + String pcmPath = ManagedFileAccess.fromPath(Files.createTempDirectory("fpcm-multithreadingTest")).getAbsolutePath(); + + final FilesystemPackageCacheManager pcm = new FilesystemPackageCacheManager.Builder().withCacheFolder(pcmPath).build(); + + Assertions.assertTrue(pcm.listPackages().isEmpty()); + + String packageAndVersion = "example.fhir.uv.myig#1.2.3"; + String lockFileName = packageAndVersion + ".lock"; + //Now sneak in a new lock file and directory: + File lockFile = ManagedFileAccess.file(pcmPath, lockFileName); + lockFile.createNewFile(); + File directory = ManagedFileAccess.file(pcmPath, packageAndVersion); + directory.mkdir(); + + // We can't create a lock file from within the same JVM, so we have to use the flock utility, which is OS dependent. + // The following works for Linux only. + ProcessBuilder processBuilder = new ProcessBuilder("flock", lockFileName, "--command", "sleep 10"); + processBuilder.directory(new File(pcmPath)); + processBuilder.start(); + + ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + + executorService.schedule(()->{ + try { + Utilities.clearDirectory(directory.getAbsolutePath()); + directory.delete(); + } catch (IOException e) { + throw new RuntimeException(e); + } + lockFile.delete(); + }, 15, TimeUnit.SECONDS); + + IOException ioException = assertThrows(IOException.class, () -> { pcm.loadPackageFromCacheOnly("example.fhir.uv.myig", "1.2.3"); }); + + + ioException.printStackTrace(); + + + } + /** We repeat the same tests multiple times here, in order to catch very rare edge cases. */ @@ -133,7 +202,7 @@ public class FilesystemPackageManagerTests { Random rand = new Random(); final AtomicInteger totalSuccessful = new AtomicInteger(); - final ConcurrentHashMap successfulThreads = new ConcurrentHashMap(); + final ConcurrentHashMap successfulThreads = new ConcurrentHashMap<>(); List threads = new ArrayList<>(); for (int i = 0; i < threadTotal; i++) { final int index = i; @@ -142,22 +211,27 @@ public class FilesystemPackageManagerTests { System.out.println("Thread #" + index + ": " + Thread.currentThread().getId() + " started"); final int randomPCM = rand.nextInt(packageCacheManagerTotal); final int randomOperation = rand.nextInt(4); + final String operationName; if (packageCacheManagers[randomPCM] == null) { packageCacheManagers[randomPCM] = new FilesystemPackageCacheManager.Builder().withCacheFolder(pcmPath).build(); } FilesystemPackageCacheManager pcm = packageCacheManagers[randomPCM]; if (randomOperation == 0) { + operationName = "addPackageToCache"; pcm.addPackageToCache("example.fhir.uv.myig", "1.2.3", this.getClass().getResourceAsStream("/npm/dummy-package.tgz"), "https://packages.fhir.org/example.fhir.uv.myig/1.2.3"); } else if (randomOperation == 1) { + operationName = "clear"; pcm.clear(); } else if (randomOperation == 2) { + operationName = "loadPackageFromCacheOnly"; pcm.loadPackageFromCacheOnly("example.fhir.uv.myig", "1.2.3"); } else { + operationName = "removePackage"; pcm.removePackage("example.fhir.uv.myig", "1.2.3"); } totalSuccessful.incrementAndGet(); successfulThreads.put(Thread.currentThread().getId(), index); - System.out.println("Thread #" + index + ": " + Thread.currentThread().getId() + " completed"); + System.out.println("Thread #" + index + ": " + Thread.currentThread().getId() + " completed. Ran: " + operationName); } catch (Exception e) { e.printStackTrace(); System.err.println("Thread #" + index + ": " + Thread.currentThread().getId() + " failed");