diff --git a/main/src/main/scala/sbt/internal/ClassLoaderCache.scala b/main/src/main/scala/sbt/internal/ClassLoaderCache.scala new file mode 100644 index 000000000..3a2c3db04 --- /dev/null +++ b/main/src/main/scala/sbt/internal/ClassLoaderCache.scala @@ -0,0 +1,98 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import java.io.File +import java.nio.file.Files + +import sbt.internal.util.TypeFunctions.Id + +import scala.annotation.tailrec + +sealed trait ClassLoaderCache + extends Repository[Id, (Seq[File], ClassLoader, Map[String, String], File), ClassLoader] + +object ClassLoaderCache { + private type Resources = Map[String, String] + private sealed trait CachedClassLoader extends ClassLoader { + def close(): Unit + } + private sealed trait StableClassLoader extends CachedClassLoader + private sealed trait SnapshotClassLoader extends CachedClassLoader + def apply(maxSize: Int): ClassLoaderCache = + new ClassLoaderCache { + private final def mktmp(tmp: File): File = + if (maxSize > 0) Files.createTempDirectory("sbt-jni").toFile else tmp + private[this] val lruCache = + LRUCache[(JarClassPath, ClassLoader), (JarClassPath, CachedClassLoader)]( + maxSize = maxSize, + onExpire = + (_: (JarClassPath, ClassLoader), v: (JarClassPath, CachedClassLoader)) => close(v._2) + ) + override def get(info: (Seq[File], ClassLoader, Resources, File)): ClassLoader = + synchronized { + val (paths, parent, resources, tmp) = info + val key @ (keyJCP, _) = (new JarClassPath(paths), parent) + def addLoader(base: Option[StableClassLoader] = None): CachedClassLoader = { + val baseLoader = base.getOrElse { + if (keyJCP.regularJars.isEmpty) new ClassLoader(parent) with StableClassLoader { + override def close(): Unit = parent match { + case s: StableClassLoader => s.close() + case _ => () + } + override def toString: String = parent.toString + } else + new LayeredClassLoader(keyJCP.regularJars, parent, resources, mktmp(tmp)) + with StableClassLoader + } + val loader: CachedClassLoader = + if (keyJCP.snapshotJars.isEmpty) baseLoader + else + new LayeredClassLoader(keyJCP.snapshotJars, baseLoader, resources, mktmp(tmp)) + with SnapshotClassLoader + lruCache.put(key, keyJCP -> loader) + loader + } + lruCache.get(key) match { + case Some((jcp, cl)) if keyJCP.strictEquals(jcp) => cl + case Some((_, cl: SnapshotClassLoader)) => + cl.close() + cl.getParent match { + case p: StableClassLoader => addLoader(Some(p)) + case _ => addLoader() + } + case None => addLoader() + } + } + override def close(): Unit = synchronized(lruCache.close()) + override def toString: String = { + import PrettyPrint.indent + val cacheElements = lruCache.entries.map { + case ((jcp, parent), (_, l)) => + s"(\n${indent(jcp, 4)},\n${indent(parent, 4)}\n) =>\n $l" + } + s"ClassLoaderCache(\n size = $maxSize,\n elements =\n${indent(cacheElements, 4)}\n)" + } + + // Close the ClassLoader and all of it's closeable parents. + @tailrec + private def close(loader: CachedClassLoader): Unit = { + loader.close() + loader.getParent match { + case c: CachedClassLoader => close(c) + case _ => () + } + } + } + def empty(newLoader: (Seq[File], ClassLoader, Resources, File) => ClassLoader): ClassLoaderCache = + new ClassLoaderCache { + override def get(key: (Seq[File], ClassLoader, Resources, File)): ClassLoader = + newLoader.tupled(key) + override def close(): Unit = {} + } +} diff --git a/main/src/main/scala/sbt/internal/JarClassPath.scala b/main/src/main/scala/sbt/internal/JarClassPath.scala new file mode 100644 index 000000000..93ed22690 --- /dev/null +++ b/main/src/main/scala/sbt/internal/JarClassPath.scala @@ -0,0 +1,55 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import java.io.File + +import sbt.io.IO + +private[internal] object JarClassPath { + class Snapshot private[internal] (val file: File, val lastModified: Long) + extends Comparable[JarClassPath.Snapshot] { + private[this] val _hash = (file.hashCode * 31) ^ java.lang.Long.valueOf(lastModified).hashCode() + def this(file: File) = this(file, IO.getModifiedTimeOrZero(file)) + override def equals(obj: Any): Boolean = obj match { + case that: JarClassPath.Snapshot => + this.lastModified == that.lastModified && this.file == that.file + case _ => false + } + override def hashCode: Int = _hash + override def compareTo(that: JarClassPath.Snapshot): Int = this.file.compareTo(that.file) + override def toString: String = + "Snapshot(path = " + file + ", lastModified = " + lastModified + ")" + } +} +private[internal] final class JarClassPath(val jars: Seq[File]) { + private[this] def isSnapshot(file: File): Boolean = file.getName contains "-SNAPSHOT" + private val jarSet = jars.toSet + val (snapshotJars, regularJars) = jars.partition(isSnapshot) + private val snapshots = snapshotJars.map(new JarClassPath.Snapshot(_)) + + override def equals(obj: Any): Boolean = obj match { + case that: JarClassPath => this.jarSet == that.jarSet + case _ => false + } + // The memoization is because codacy isn't smart enough to identify that + // `override lazy val hashCode: Int = jarSet.hashCode` does actually override hashCode and it + // complains that equals and hashCode were not implemented together. + private[this] lazy val _hashCode: Int = jarSet.hashCode + override def hashCode: Int = _hashCode + override def toString: String = + s"JarClassPath(\n jars =\n ${regularJars.mkString(",\n ")}" + + s", snapshots =\n${snapshots.mkString(",\n ")}\n)" + + /* + * This is a stricter equality requirement than equals that we can use for cache invalidation. + */ + private[internal] def strictEquals(that: JarClassPath): Boolean = { + this.equals(that) && !this.snapshots.view.zip(that.snapshots).exists { case (l, r) => l != r } + } +} diff --git a/main/src/main/scala/sbt/internal/LayeredClassLoader.scala b/main/src/main/scala/sbt/internal/LayeredClassLoader.scala new file mode 100644 index 000000000..20fe9564d --- /dev/null +++ b/main/src/main/scala/sbt/internal/LayeredClassLoader.scala @@ -0,0 +1,68 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import java.io.File +import java.net.URLClassLoader +import java.{ util => jutil } +import scala.collection.JavaConverters._ + +import sbt.internal.inc.classpath._ +import sbt.io.IO + +private[sbt] class LayeredClassLoader( + classpath: Seq[File], + parent: ClassLoader, + override protected val resources: Map[String, String], + tempDir: File, +) extends URLClassLoader(classpath.toArray.map(_.toURI.toURL), parent) + with RawResources + with NativeCopyLoader + with AutoCloseable { + private[this] val nativeLibs = new jutil.HashSet[File]().asScala + override protected val config = new NativeCopyConfig( + tempDir, + classpath, + IO.parseClasspath(System.getProperty("java.library.path", "")) + ) + override def findLibrary(name: String): String = { + super.findLibrary(name) match { + case null => null + case l => + nativeLibs += new File(l) + l + } + } + override def close(): Unit = nativeLibs.foreach(NativeLibs.delete) + override def toString: String = s"""LayeredClassLoader( + | classpath = + | ${classpath mkString "\n "} + | parent = + | ${parent.toString.lines.mkString("\n ")} + |)""".stripMargin +} + +private[internal] object NativeLibs { + private[this] val nativeLibs = new jutil.HashSet[File].asScala + Runtime.getRuntime.addShutdownHook(new Thread("sbt.internal.native-library-deletion") { + override def run(): Unit = { + nativeLibs.foreach(IO.delete) + IO.deleteIfEmpty(nativeLibs.map(_.getParentFile).toSet) + nativeLibs.clear() + } + }) + def addNativeLib(lib: String): Unit = { + nativeLibs.add(new File(lib)) + () + } + def delete(file: File): Unit = { + nativeLibs.remove(file) + file.delete() + () + } +} diff --git a/main/src/main/scala/sbt/internal/PrettyPrint.scala b/main/src/main/scala/sbt/internal/PrettyPrint.scala new file mode 100644 index 000000000..a769f4ce9 --- /dev/null +++ b/main/src/main/scala/sbt/internal/PrettyPrint.scala @@ -0,0 +1,15 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +private[sbt] object PrettyPrint { + private[sbt] def indent(any: Any, level: Int): String = { + val i = " " * level + any.toString.lines.mkString(i, "\n" + i, "") + } +} diff --git a/main/src/test/scala/sbt/internal/ClassLoaderCacheTest.scala b/main/src/test/scala/sbt/internal/ClassLoaderCacheTest.scala new file mode 100644 index 000000000..201964666 --- /dev/null +++ b/main/src/test/scala/sbt/internal/ClassLoaderCacheTest.scala @@ -0,0 +1,68 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal + +import java.io.File +import java.nio.file.Files + +import org.scalatest.{ FlatSpec, Matchers } +import sbt.io.IO + +object ClassLoaderCacheTest { + private val initLoader = this.getClass.getClassLoader + implicit class CacheOps(val c: ClassLoaderCache) { + def get(classpath: Seq[File]): ClassLoader = + c.get((classpath, initLoader, Map.empty, new File("/dev/null"))) + } +} +class ClassLoaderCacheTest extends FlatSpec with Matchers { + import ClassLoaderCacheTest._ + def withCache[R](size: Int)(f: CacheOps => R): R = { + val cache = ClassLoaderCache(size) + try f(new CacheOps(cache)) + finally cache.close() + } + "ClassLoaderCache.get" should "make a new loader when full" in withCache(0) { cache => + val classPath = Seq.empty[File] + val firstLoader = cache.get(classPath) + val secondLoader = cache.get(classPath) + assert(firstLoader != secondLoader) + } + it should "not make a new loader when it already exists" in withCache(1) { cache => + val classPath = Seq.empty[File] + val firstLoader = cache.get(classPath) + val secondLoader = cache.get(classPath) + assert(firstLoader == secondLoader) + } + it should "evict loaders" in withCache(2) { cache => + val firstClassPath = Seq.empty[File] + val secondClassPath = new File("foo") :: Nil + val thirdClassPath = new File("foo") :: new File("bar") :: Nil + val firstLoader = cache.get(firstClassPath) + val secondLoader = cache.get(secondClassPath) + val thirdLoader = cache.get(thirdClassPath) + assert(cache.get(thirdClassPath) == thirdLoader) + assert(cache.get(secondClassPath) == secondLoader) + assert(cache.get(firstClassPath) != firstLoader) + assert(cache.get(thirdClassPath) != thirdLoader) + } + "Snapshots" should "be invalidated" in IO.withTemporaryDirectory { dir => + val snapshotJar = Files.createFile(dir.toPath.resolve("foo-SNAPSHOT.jar")).toFile + val regularJar = Files.createFile(dir.toPath.resolve("regular.jar")).toFile + withCache(1) { cache => + val jarClassPath = snapshotJar :: regularJar :: Nil + val initLoader = cache.get(jarClassPath) + IO.setModifiedTimeOrFalse(snapshotJar, System.currentTimeMillis + 5000L) + val secondLoader = cache.get(jarClassPath) + assert(initLoader != secondLoader) + assert(initLoader.getParent == secondLoader.getParent) + assert(cache.get(jarClassPath) == secondLoader) + assert(cache.get(jarClassPath) != initLoader) + } + } +}