From 2ee7c26d1e191630d09337bc6c7bf0f815ea8542 Mon Sep 17 00:00:00 2001 From: since-2017-hub <34567183+since-2017-hub@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:58:33 -0700 Subject: [PATCH] [2.x] Fix duplicate compiler bridge jar in ZincComponentManager (#8800) * Fix duplicate compiler bridge jar in ZincComponentManager * Fix update method to clean stale stamped jars in ZincComponentManager * Fix update method to prevent duplicate jars from secondary cache lookups in ZincComponentManagerSpec Generated-by: GitHub Copilot --- .../internal/inc/ZincComponentManager.scala | 32 ++- .../inc/ZincComponentManagerTest.scala | 206 ++++++++++++++++++ 2 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 zinc-lm-integration/src/test/scala/sbt/internal/inc/ZincComponentManagerTest.scala diff --git a/zinc-lm-integration/src/main/scala/sbt/internal/inc/ZincComponentManager.scala b/zinc-lm-integration/src/main/scala/sbt/internal/inc/ZincComponentManager.scala index 464dba10e..68e156beb 100644 --- a/zinc-lm-integration/src/main/scala/sbt/internal/inc/ZincComponentManager.scala +++ b/zinc-lm-integration/src/main/scala/sbt/internal/inc/ZincComponentManager.scala @@ -74,6 +74,14 @@ class ZincComponentManager( def file(id: String)(ifMissing: IfMissing): File = { files(id)(ifMissing).toList match { case x :: Nil => x + case xs if xs.size > 1 => + val canonical = xs.find(_.getName == s"$id.jar").getOrElse(xs.head) + val toRemove = xs.filterNot(_ == canonical) + toRemove.foreach(f => IO.delete(f)) + log.warn( + s"Multiple files found for component '$id', removing extras: ${toRemove.mkString(", ")}" + ) + canonical case xs => invalid(s"Expected single file for component '$id', found: ${xs.mkString(", ")}") } } @@ -97,12 +105,28 @@ class ZincComponentManager( private def invalid(msg: String) = throw new InvalidComponent(msg) - /** Retrieve the file for component 'id' from the secondary cache. */ + /** + * Retrieve the file for component 'id' from the secondary cache. + * + * The secondary cache stores jars with a stamped version in the file name + * (e.g. `id-1.10.5_20241130T035052.jar`). When `defineComponent` copies this file + * into the local component directory, it preserves the original file name. + * If the stamped version later changes, a new file is created alongside the old one, + * causing `file()` to find multiple jars for the same component. + * + * To prevent this, we first remove any existing files in the component directory + * before defining the component with the canonical name (`id.jar`). + */ private def update(id: String): Unit = { secondaryCacheDir.foreach { dir => - val file = secondaryCacheFile(id, dir) - if (file.exists) { - define(id, Seq(file)) + val secondary = secondaryCacheFile(id, dir) + if (secondary.exists) { + val componentDir = provider.componentLocation(id) + if (componentDir.isDirectory) { + IO.listFiles(componentDir).foreach(IO.delete) + } + val canonicalJar = new File(componentDir, s"$id.jar") + IO.copyFile(secondary, canonicalJar) } } } diff --git a/zinc-lm-integration/src/test/scala/sbt/internal/inc/ZincComponentManagerTest.scala b/zinc-lm-integration/src/test/scala/sbt/internal/inc/ZincComponentManagerTest.scala new file mode 100644 index 000000000..b9a968be7 --- /dev/null +++ b/zinc-lm-integration/src/test/scala/sbt/internal/inc/ZincComponentManagerTest.scala @@ -0,0 +1,206 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal +package inc + +import java.io.File +import java.util.concurrent.Callable + +import hedgehog.* +import hedgehog.runner.* +import hedgehog.core.Result +import _root_.sbt.io.IO +import _root_.sbt.io.syntax.* +import xsbti.{ ComponentProvider, GlobalLock, Logger } + +object ZincComponentManagerTest extends Properties: + override def tests: List[Test] = List( + example("files should return defined component files", testFilesReturnsDefined), + example( + "files should throw InvalidComponent when component is missing and IfMissing.Fail", + testFilesMissingFail, + ), + example("file should return single file for a component", testFileSingle), + example("file should throw when multiple files exist for a component", testFileMultiple), + example("define should register component files", testDefine), + example("files should call IfMissing.Define when component is missing", testFilesMissingDefine), + example( + "files should cache to secondary cache when IfMissing.Define with useSecondaryCache", + testSecondaryCacheWrite, + ), + example( + "files should retrieve from secondary cache when component is missing locally", + testSecondaryCacheRead, + ), + ) + + private val noOpLock: GlobalLock = new GlobalLock: + override def apply[T](file: File, callable: Callable[T]): T = callable.call() + + private val silentLogger: Logger = new Logger: + override def error(msg: java.util.function.Supplier[String]): Unit = () + override def warn(msg: java.util.function.Supplier[String]): Unit = () + override def info(msg: java.util.function.Supplier[String]): Unit = () + override def debug(msg: java.util.function.Supplier[String]): Unit = () + override def trace(exception: java.util.function.Supplier[Throwable]): Unit = () + + private def withTempDir[A](f: File => A): A = + IO.withTemporaryDirectory(f) + + private def fileProvider(baseDir: File): ComponentProvider = new ComponentProvider: + private def componentDir(id: String): File = + val dir = baseDir / id + IO.createDirectory(dir) + dir + + override def componentLocation(id: String): File = componentDir(id) + + override def component(componentID: String): Array[File] = + val dir = componentDir(componentID) + if dir.exists() then dir.listFiles().filter(_.isFile) + else Array.empty + + override def defineComponent(componentID: String, files: Array[File]): Unit = + val dir = componentDir(componentID) + files.foreach: f => + IO.copyFile(f, dir / f.getName) + + override def addToComponent(componentID: String, files: Array[File]): Boolean = + defineComponent(componentID, files) + true + + override def lockFile(): File = baseDir / ".lock" + + private def createTempJar(dir: File, name: String): File = + val f = dir / name + IO.write(f, "fake-jar-content") + f + + def testFilesReturnsDefined: Result = withTempDir: tmpDir => + val provider = fileProvider(tmpDir / "components") + val manager = new ZincComponentManager(noOpLock, provider, None, silentLogger) + val sourceDir = tmpDir / "source" + IO.createDirectory(sourceDir) + val jar = createTempJar(sourceDir, "bridge.jar") + manager.define("test-component", Seq(jar)) + val result = manager.files("test-component")(IfMissing.fail) + Result + .assert(result.nonEmpty) + .log(s"expected non-empty files, got: ${result.toList}") + + def testFilesMissingFail: Result = withTempDir: tmpDir => + val provider = fileProvider(tmpDir / "components") + val manager = new ZincComponentManager(noOpLock, provider, None, silentLogger) + try + manager.files("nonexistent")(IfMissing.fail) + Result.failure.log("expected InvalidComponent to be thrown") + catch + case _: InvalidComponent => Result.success + case e: Throwable => + Result.failure.log(s"unexpected exception: ${e.getClass.getName}: ${e.getMessage}") + + def testFileSingle: Result = withTempDir: tmpDir => + val provider = fileProvider(tmpDir / "components") + val manager = new ZincComponentManager(noOpLock, provider, None, silentLogger) + val sourceDir = tmpDir / "source" + IO.createDirectory(sourceDir) + val jar = createTempJar(sourceDir, "single.jar") + manager.define("single-component", Seq(jar)) + val result = manager.file("single-component")(IfMissing.fail) + Result + .assert(result.getName == "single.jar") + .log(s"expected single.jar, got: ${result.getName}") + + def testFileMultiple: Result = withTempDir: tmpDir => + val provider = fileProvider(tmpDir / "components") + val manager = new ZincComponentManager(noOpLock, provider, None, silentLogger) + val sourceDir = tmpDir / "source" + IO.createDirectory(sourceDir) + val jar1 = createTempJar(sourceDir, "multi-component.jar") + val jar2 = createTempJar(sourceDir, "b.jar") + manager.define("multi-component", Seq(jar1, jar2)) + val result = manager.file("multi-component")(IfMissing.fail) + val remaining = provider.component("multi-component").filter(_.isFile) + Result + .assert(result.getName == "multi-component.jar") + .and(Result.assert(remaining.length == 1)) + .log(s"got: ${result.getName}, remaining: ${remaining.toList.map(_.getName)}") + + def testDefine: Result = withTempDir: tmpDir => + val provider = fileProvider(tmpDir / "components") + val manager = new ZincComponentManager(noOpLock, provider, None, silentLogger) + val sourceDir = tmpDir / "source" + IO.createDirectory(sourceDir) + val jar = createTempJar(sourceDir, "defined.jar") + manager.define("def-component", Seq(jar)) + val files = provider.component("def-component") + Result + .assert(files.length == 1) + .and(Result.assert(files.head.getName == "defined.jar")) + .log(s"files: ${files.toList.map(_.getName)}") + + def testFilesMissingDefine: Result = withTempDir: tmpDir => + val secondaryDir = tmpDir / "secondary" + IO.createDirectory(secondaryDir) + val provider = fileProvider(tmpDir / "components") + val manager = new ZincComponentManager(noOpLock, provider, Some(secondaryDir), silentLogger) + val sourceDir = tmpDir / "source" + IO.createDirectory(sourceDir) + val jar = createTempJar(sourceDir, "created.jar") + val ifMissing = IfMissing.define( + false, + manager.define("lazy-component", Seq(jar)), + ) + val result = manager.files("lazy-component")(ifMissing) + Result + .assert(result.nonEmpty) + .log(s"expected non-empty files after define, got: ${result.toList}") + + def testSecondaryCacheWrite: Result = withTempDir: tmpDir => + val secondaryDir = tmpDir / "secondary" + IO.createDirectory(secondaryDir) + val provider = fileProvider(tmpDir / "components") + val manager = new ZincComponentManager(noOpLock, provider, Some(secondaryDir), silentLogger) + val sourceDir = tmpDir / "source" + IO.createDirectory(sourceDir) + val jar = createTempJar(sourceDir, "cached.jar") + val ifMissing = IfMissing.define( + true, + manager.define("cache-component", Seq(jar)), + ) + manager.files("cache-component")(ifMissing) + val sbtOrgDir = secondaryDir / "org.scala-sbt" + val cachedFiles = if sbtOrgDir.exists() then sbtOrgDir.listFiles().toList else Nil + Result + .assert(cachedFiles.nonEmpty) + .log(s"expected files in secondary cache dir, got: $cachedFiles") + + def testSecondaryCacheRead: Result = withTempDir: tmpDir => + val secondaryDir = tmpDir / "secondary" + IO.createDirectory(secondaryDir) + val provider1 = fileProvider(tmpDir / "components1") + val manager1 = new ZincComponentManager(noOpLock, provider1, Some(secondaryDir), silentLogger) + val sourceDir = tmpDir / "source" + IO.createDirectory(sourceDir) + val jar = createTempJar(sourceDir, "shared.jar") + val ifMissing1 = IfMissing.define( + true, + manager1.define("shared-component", Seq(jar)), + ) + manager1.files("shared-component")(ifMissing1) + + val provider2 = fileProvider(tmpDir / "components2") + val manager2 = new ZincComponentManager(noOpLock, provider2, Some(secondaryDir), silentLogger) + val result = manager2.files("shared-component")(IfMissing.fail) + Result + .assert(result.nonEmpty) + .log(s"expected to retrieve component from secondary cache, got: ${result.toList}") + +end ZincComponentManagerTest