mirror of
https://github.com/sbt/sbt.git
synced 2026-09-01 10:37:54 +02:00
Merge pull request #9501 from eed3si9n/bport2/backports
[2.0.x] Backport fixes
This commit is contained in:
@@ -674,6 +674,14 @@ lazy val coreMacrosProj = (project in file("core-macros"))
|
||||
name := "Core Macros",
|
||||
SettingKey[Boolean]("exportPipelining") := false,
|
||||
mimaSettings,
|
||||
mimaBinaryIssueFilters ++= Seq(
|
||||
// macro-expansion internals; Output's per-call-site var codegen members were removed
|
||||
ProblemFilters.exclude[DirectMissingMethodProblem](
|
||||
"sbt.internal.util.appmacro.ContextUtil#Output.*"
|
||||
),
|
||||
exclude[DirectMissingMethodProblem]("sbt.internal.util.appmacro.ContextUtil#Input.*"),
|
||||
exclude[ReversedMissingMethodProblem]("sbt.internal.util.appmacro.ContextUtil.*"),
|
||||
),
|
||||
)
|
||||
|
||||
// Fixes scope=Scope for Setting (core defined in collectionProj) to define the settings system used in build definitions
|
||||
|
||||
@@ -192,6 +192,16 @@ trait Cont:
|
||||
|
||||
val inputBuf = ListBuffer[Input]()
|
||||
val outputBuf = ListBuffer[Output]()
|
||||
lazy val outputAccSym: Symbol =
|
||||
Symbol.newVal(
|
||||
Symbol.spliceOwner,
|
||||
freshName("outputs"),
|
||||
TypeRepr.of[ListBuffer[VirtualFile]],
|
||||
Flags.EmptyFlags,
|
||||
Symbol.noSymbol
|
||||
)
|
||||
def outputAccRef: Expr[ListBuffer[VirtualFile]] =
|
||||
Ref(outputAccSym).asExprOf[ListBuffer[VirtualFile]]
|
||||
|
||||
def unitExpr: Expr[Unit] = '{ () }
|
||||
|
||||
@@ -306,7 +316,7 @@ trait Cont:
|
||||
.asExprOf[BuildWideCacheConfiguration]
|
||||
inputs.foreach: input =>
|
||||
if !input.isCacheInput then
|
||||
if !Cont.transientAllowSet(input.sym.name) then
|
||||
if !Cont.transientAllowSet(input.sym.name) && !input.isWarnSuppressed then
|
||||
report.warning(
|
||||
s"transient key ${input.sym.name} is excluded from the cache input"
|
||||
)
|
||||
@@ -405,31 +415,37 @@ trait Cont:
|
||||
}
|
||||
|
||||
// This will generate following code for Def.declareOutput(...):
|
||||
// var $o1: VirtualFile = null
|
||||
// ActionCache.ActionResult({
|
||||
// val $outputs = ListBuffer.empty[VirtualFile]
|
||||
// ActionCache.InternalActionResult({
|
||||
// body...
|
||||
// $o1 = out // Def.declareOutput(out)
|
||||
// ActionCache.registerOutput(out, $outputs) // Def.declareOutput(out)
|
||||
// result
|
||||
// }, List($o1))
|
||||
// }, $outputs.toList)
|
||||
def letOutput[A1: Type](
|
||||
outputs: List[Output],
|
||||
cacheConfigExpr: Expr[BuildWideCacheConfiguration],
|
||||
)(body: Expr[A1]): Expr[ActionCache.InternalActionResult[A1]] =
|
||||
Block(
|
||||
outputs.map(_.toVarDef),
|
||||
if outputs.isEmpty then
|
||||
'{
|
||||
ActionCache.InternalActionResult(
|
||||
value = $body,
|
||||
outputs = List(${
|
||||
Varargs[VirtualFile](outputs.map: out =>
|
||||
out.toRef.asExprOf[VirtualFile])
|
||||
}*),
|
||||
outputs = Nil,
|
||||
)
|
||||
}.asTerm
|
||||
).asExprOf[ActionCache.InternalActionResult[A1]]
|
||||
}
|
||||
else
|
||||
Block(
|
||||
ValDef(outputAccSym, Some('{ ListBuffer.empty[VirtualFile] }.asTerm)) :: Nil,
|
||||
'{
|
||||
ActionCache.InternalActionResult(
|
||||
value = $body,
|
||||
outputs = $outputAccRef.toList,
|
||||
)
|
||||
}.asTerm
|
||||
).asExprOf[ActionCache.InternalActionResult[A1]]
|
||||
|
||||
val WrapOutputName = "wrapOutput_\u2603\u2603"
|
||||
val WrapOutputDirectoryName = "wrapOutputDirectory_\u2603\u2603"
|
||||
var nowarnQuals: Set[Term] = Set.empty
|
||||
// Called when transforming the tree to add an input.
|
||||
// For `qual` of type F[A], and a `selection` qual.value.
|
||||
val record = [a] =>
|
||||
@@ -441,12 +457,16 @@ trait Cont:
|
||||
val output = Output(
|
||||
tpe = TypeRepr.of[a],
|
||||
term = qual,
|
||||
name = freshName("o"),
|
||||
parent = Symbol.spliceOwner,
|
||||
outputType = OutputType.File
|
||||
outputType = OutputType.File,
|
||||
)
|
||||
outputBuf += output
|
||||
if cacheConfigExprOpt.isDefined then output.toAssign(output.term)
|
||||
if cacheConfigExprOpt.isDefined then
|
||||
'{
|
||||
ActionCache.registerOutput(
|
||||
${ output.term.asExprOf[VirtualFile] },
|
||||
$outputAccRef,
|
||||
)
|
||||
}.asTerm
|
||||
else oldTree
|
||||
case WrapOutputDirectoryName =>
|
||||
val output = Output(
|
||||
@@ -454,28 +474,35 @@ trait Cont:
|
||||
// which contains hash.
|
||||
tpe = TypeRepr.of[VirtualFile],
|
||||
term = qual,
|
||||
name = freshName("o"),
|
||||
parent = Symbol.spliceOwner,
|
||||
outputType = OutputType.Directory,
|
||||
)
|
||||
outputBuf += output
|
||||
cacheConfigExprOpt match
|
||||
case Some(cacheConfigExpr) =>
|
||||
output.toAssign('{
|
||||
ActionCache.packageDirectory(
|
||||
dir = ${ output.term.asExprOf[VirtualFileRef] },
|
||||
conv = $cacheConfigExpr.fileConverter,
|
||||
outputDirectory = $cacheConfigExpr.outputDirectory,
|
||||
'{
|
||||
ActionCache.registerOutput(
|
||||
ActionCache.packageDirectory(
|
||||
dir = ${ output.term.asExprOf[VirtualFileRef] },
|
||||
conv = $cacheConfigExpr.fileConverter,
|
||||
outputDirectory = $cacheConfigExpr.outputDirectory,
|
||||
),
|
||||
$outputAccRef,
|
||||
)
|
||||
}.asTerm)
|
||||
}.asTerm
|
||||
case None => oldTree
|
||||
case _ =>
|
||||
// todo cache opt-out attribute
|
||||
inputBuf += Input(TypeRepr.of[a], qual, replacement, freshName("q"))
|
||||
inputBuf += Input(
|
||||
TypeRepr.of[a],
|
||||
qual,
|
||||
replacement,
|
||||
freshName("q"),
|
||||
isWarnSuppressed = nowarnQuals.contains(qual),
|
||||
)
|
||||
oldTree
|
||||
}
|
||||
val exprWithConfig =
|
||||
cacheConfigExprOpt.map(config => '{ $config; $expr }).getOrElse(expr)
|
||||
nowarnQuals = collectNowarnQuals(exprWithConfig.asTerm)
|
||||
val body = transformWrappers(exprWithConfig.asTerm, record, Symbol.spliceOwner)
|
||||
val r = inputBuf.toList match
|
||||
case Nil => pure(body)
|
||||
|
||||
@@ -76,11 +76,13 @@ trait ContextUtil[C <: Quotes & scala.Singleton](val valStart: Int):
|
||||
|
||||
private val cacheLevelSym = Symbol.requiredClass("sbt.util.cacheLevel")
|
||||
private val transientSym = Symbol.requiredClass("scala.transient")
|
||||
private val nowarnAnnotSym = Symbol.requiredClass("scala.annotation.nowarn")
|
||||
final class Input(
|
||||
val tpe: TypeRepr,
|
||||
val qual: Term,
|
||||
val term: Term,
|
||||
val name: String,
|
||||
val isWarnSuppressed: Boolean,
|
||||
):
|
||||
override def toString: String =
|
||||
s"Input($tpe, $qual, $term, $name, $tags)"
|
||||
@@ -155,31 +157,10 @@ trait ContextUtil[C <: Quotes & scala.Singleton](val valStart: Int):
|
||||
final class Output(
|
||||
val tpe: TypeRepr,
|
||||
val term: Term,
|
||||
val name: String,
|
||||
val parent: Symbol,
|
||||
val outputType: OutputType,
|
||||
):
|
||||
override def toString: String =
|
||||
s"Output($tpe, $term, $name, $outputType)"
|
||||
val placeholder: Symbol =
|
||||
tpe.asType match
|
||||
case '[a] =>
|
||||
Symbol.newVal(
|
||||
parent,
|
||||
name,
|
||||
tpe,
|
||||
Flags.Mutable,
|
||||
Symbol.noSymbol
|
||||
)
|
||||
def toVarDef: ValDef =
|
||||
ValDef(placeholder, rhs = Some('{ null }.asTerm))
|
||||
def toAssign(value: Term): Term =
|
||||
Block(
|
||||
Assign(toRef, value) :: Nil,
|
||||
toRef
|
||||
)
|
||||
def toRef: Ref = Ref(placeholder)
|
||||
def isFile: Boolean = outputType == OutputType.File
|
||||
s"Output($tpe, $term, $outputType)"
|
||||
end Output
|
||||
|
||||
def applyTuple(tupleTerm: Term, tpe: TypeRepr, idx: Int): Term =
|
||||
@@ -194,6 +175,34 @@ trait ContextUtil[C <: Quotes & scala.Singleton](val valStart: Int):
|
||||
|
||||
def idTransform[F[_]]: TermTransform[F] = in => in
|
||||
|
||||
def collectNowarnQuals(tree: Term): Set[Term] =
|
||||
val result = mutable.HashSet[Term]()
|
||||
@tailrec def extractQual(t: Term): Unit = t match
|
||||
case Inlined(_, _, inner) => extractQual(inner)
|
||||
case Typed(inner, _) => extractQual(inner)
|
||||
case Apply(TypeApply(Select(_, _), _ :: Nil), qual :: Nil) => result += qual
|
||||
case Apply(TypeApply(Ident(_), _ :: Nil), qual :: Nil) => result += qual
|
||||
case _ => ()
|
||||
def targetsTransient(s: String) = s.isEmpty || s.startsWith("msg=transient")
|
||||
object scanner extends TreeTraverser:
|
||||
override def traverseTree(t: Tree)(owner: Symbol): Unit = t match
|
||||
case Typed(inner, tpt) =>
|
||||
tpt.tpe match
|
||||
case AnnotatedType(_, annot) if annot.tpe.typeSymbol == nowarnAnnotSym =>
|
||||
val isUnfiltered = annot match
|
||||
case Apply(_, Nil) => true
|
||||
case Apply(_, Literal(StringConstant(s)) :: Nil) => targetsTransient(s)
|
||||
case Apply(_, NamedArg(_, Literal(StringConstant(s))) :: Nil) => targetsTransient(s)
|
||||
case Apply(_, _ :: Nil) => true
|
||||
case _ => false
|
||||
if isUnfiltered then extractQual(inner)
|
||||
case _ =>
|
||||
super.traverseTree(t)(owner)
|
||||
case _ => super.traverseTree(t)(owner)
|
||||
end scanner
|
||||
scanner.traverseTree(tree)(Symbol.spliceOwner)
|
||||
result.toSet
|
||||
|
||||
def collectDefs(tree: Term, isWrapper: (String, TypeRepr, Term) => Boolean): Set[Symbol] =
|
||||
val defs = mutable.HashSet[Symbol]()
|
||||
object traverser extends TreeTraverser:
|
||||
|
||||
@@ -116,18 +116,13 @@ private[sbt] object ConvertResolver {
|
||||
checksum <- checksums
|
||||
if !ChecksumHelper.isKnownAlgorithm(checksum)
|
||||
} throw new IllegalArgumentException("Unknown checksum algorithm: " + checksum)
|
||||
repository.put(artifact, src, dest, overwrite);
|
||||
// Fix for sbt#1156 - Artifactory will auto-generate MD5/sha1 files, so
|
||||
// we need to overwrite what it has.
|
||||
if (!artifact.getName.endsWith(".asc")) {
|
||||
for (checksum <- checksums) {
|
||||
putChecksumMethod match {
|
||||
repository.put(artifact, src, dest, overwrite)
|
||||
if !dest.endsWith(".asc") then
|
||||
for checksum <- checksums do
|
||||
putChecksumMethod match
|
||||
case Some(method) =>
|
||||
method.invoke(this, artifact, src, dest, true: java.lang.Boolean, checksum)
|
||||
case None => // TODO - issue warning?
|
||||
}
|
||||
}
|
||||
}
|
||||
if (signerName != null) {
|
||||
putSignatureMethod match {
|
||||
case None => ()
|
||||
@@ -219,15 +214,16 @@ private[sbt] object ConvertResolver {
|
||||
resolver
|
||||
}
|
||||
case repo: FileRepository => {
|
||||
val resolver = new FileSystemResolver with DescriptorRequired {
|
||||
// Workaround for #1156
|
||||
// Temporarily in sbt 0.13.x we deprecate overwriting
|
||||
// in local files for non-changing revisions.
|
||||
// This will be fully enforced in sbt 1.0.
|
||||
setRepository(new WarnOnOverwriteFileRepo())
|
||||
override val managedChecksumsEnabled: Boolean = managedChecksums
|
||||
override def getResource(resource: Resource, dest: File): Long = get(resource, dest)
|
||||
}
|
||||
val resolver =
|
||||
new FileSystemResolver with ChecksumFriendlyURLResolver with DescriptorRequired {
|
||||
// Workaround for #1156
|
||||
// Temporarily in sbt 0.13.x we deprecate overwriting
|
||||
// in local files for non-changing revisions.
|
||||
// This will be fully enforced in sbt 1.0.
|
||||
setRepository(new WarnOnOverwriteFileRepo())
|
||||
override val managedChecksumsEnabled: Boolean = managedChecksums
|
||||
override def getResource(resource: Resource, dest: File): Long = get(resource, dest)
|
||||
}
|
||||
resolver.setName(repo.name)
|
||||
initializePatterns(resolver, repo.patterns, settings)
|
||||
import repo.configuration.{ isLocal, isTransactional }
|
||||
|
||||
@@ -77,12 +77,34 @@ private[sbt] class ClassLoaderCache(
|
||||
new java.util.concurrent.ConcurrentHashMap[Key, Reference[ClassLoader]]()
|
||||
private val referenceQueue = new ReferenceQueue[ClassLoader]
|
||||
|
||||
/*
|
||||
* Loaders evicted from `delegate` by clearExpiredLoaders are no longer reachable from the
|
||||
* map, so clear()/close() alone can never close them. Nor can the cleanup thread: once the
|
||||
* entry is removed, the Reference object itself becomes unreachable, and an unreachable
|
||||
* Reference is never enqueued on the ReferenceQueue. They would linger with open jar handles
|
||||
* for the life of the JVM. On Windows those handles make the underlying jars undeletable
|
||||
* (e.g. clearCaches cannot delete cas blobs the loaders still reference). Track evicted
|
||||
* loaders weakly so clear() can close them deterministically; weak keys preserve the
|
||||
* metaspace-pressure design above by adding no strong retention of their own.
|
||||
*/
|
||||
private val retired =
|
||||
java.util.Collections.synchronizedMap(new java.util.WeakHashMap[ClassLoader, java.lang.Boolean])
|
||||
|
||||
private def clearExpiredLoaders(): Unit = lock.synchronized {
|
||||
val clear = (k: Key, ref: Reference[ClassLoader]) => {
|
||||
ref.get() match {
|
||||
case w: WrappedLoader => w.invalidate()
|
||||
case _ =>
|
||||
}
|
||||
ref match {
|
||||
case ClassLoaderReference(_, underlying) =>
|
||||
retired.put(underlying, java.lang.Boolean.TRUE)
|
||||
case r =>
|
||||
r.get() match {
|
||||
case null =>
|
||||
case loader => retired.put(loader, java.lang.Boolean.TRUE)
|
||||
}
|
||||
}
|
||||
delegate.remove(k)
|
||||
()
|
||||
}
|
||||
@@ -109,6 +131,7 @@ private[sbt] class ClassLoaderCache(
|
||||
referenceQueue.remove(1000) match {
|
||||
case ClassLoaderReference(key, classLoader) =>
|
||||
close(classLoader)
|
||||
retired.remove(classLoader)
|
||||
delegate.remove(key)
|
||||
()
|
||||
case _ =>
|
||||
@@ -148,6 +171,10 @@ private[sbt] class ClassLoaderCache(
|
||||
* handle from being modified. On linux and mac, we probably leak some file descriptors but it's
|
||||
* fairly uncommon for sbt to run out of file descriptors.
|
||||
*
|
||||
* Loaders evicted by clearExpiredLoaders (as opposed to by garbage collection) are a separate
|
||||
* case: they are removed from `delegate`, so neither clear()/close() nor the reference-queue
|
||||
* cleanup thread can reach them. Those are tracked weakly in `retired` and closed by
|
||||
* clear()/close(), so their handles are released deterministically rather than never.
|
||||
*/
|
||||
private val metaspaceIsLimited =
|
||||
ManagementFactory.getMemoryPoolMXBeans.asScala
|
||||
@@ -230,6 +257,12 @@ private[sbt] class ClassLoaderCache(
|
||||
}
|
||||
}
|
||||
delegate.clear()
|
||||
/* Also close loaders that were evicted from the delegate map but never closed (see
|
||||
* `retired`); WeakHashMap iteration requires holding its monitor, so snapshot the keys
|
||||
* first and close outside the lock. */
|
||||
val evicted = retired.synchronized(new java.util.ArrayList(retired.keySet()).asScala.toList)
|
||||
evicted.foreach(close)
|
||||
retired.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -375,33 +375,19 @@ class NetworkClient(
|
||||
term.isSupershellEnabled
|
||||
).mkString(",")
|
||||
|
||||
val cmd = arguments.sbtLaunchJar match {
|
||||
case Some(lj) =>
|
||||
if (log) {
|
||||
val sbtScript = if (Properties.isWin) "sbt.bat" else "sbt"
|
||||
console.appendLog(Level.Warn, s"server is started using sbt-launch jar directly")
|
||||
console.appendLog(
|
||||
Level.Warn,
|
||||
"this is not the recommended way: .sbtopts and .jvmopts files are not loaded and SBT_OPTS is ignored"
|
||||
)
|
||||
console.appendLog(
|
||||
Level.Warn,
|
||||
s"either upgrade $sbtScript to its latest version or make sure it is accessible from $$PATH, and run 'sbt bspConfig'"
|
||||
)
|
||||
}
|
||||
val java = Option(Properties.javaHome)
|
||||
.map { javaHome =>
|
||||
s"$javaHome/bin/java"
|
||||
}
|
||||
.getOrElse("java")
|
||||
List(java) ++ arguments.sbtArguments.filterNot(
|
||||
NetworkClient.emptyBuildFlags.contains
|
||||
) ++
|
||||
List("-jar", lj, DashDashDetachStdio, DashDashServer)
|
||||
case _ =>
|
||||
List(arguments.sbtScript) ++ arguments.sbtArguments ++
|
||||
List(DashDashDetachStdio, DashDashServer)
|
||||
if (log && arguments.sbtLaunchJar.isDefined) {
|
||||
val sbtScript = if (Properties.isWin) "sbt.bat" else "sbt"
|
||||
console.appendLog(Level.Warn, s"server is started using sbt-launch jar directly")
|
||||
console.appendLog(
|
||||
Level.Warn,
|
||||
"this is not the recommended way: .sbtopts and .jvmopts files are not loaded and SBT_OPTS is ignored"
|
||||
)
|
||||
console.appendLog(
|
||||
Level.Warn,
|
||||
s"either upgrade $sbtScript to its latest version or make sure it is accessible from $$PATH, and run 'sbt bspConfig'"
|
||||
)
|
||||
}
|
||||
val cmd = NetworkClient.serverCommand(arguments)
|
||||
|
||||
// https://github.com/sbt/sbt/issues/6271
|
||||
val nohup =
|
||||
@@ -1202,6 +1188,7 @@ object NetworkClient {
|
||||
val sbtScript: String,
|
||||
val bsp: Boolean,
|
||||
val sbtLaunchJar: Option[String],
|
||||
val launcherValueArgs: Seq[String] = Nil,
|
||||
) {
|
||||
def withBaseDirectory(file: File): Arguments =
|
||||
new Arguments(
|
||||
@@ -1212,8 +1199,20 @@ object NetworkClient {
|
||||
sbtScript,
|
||||
bsp,
|
||||
sbtLaunchJar,
|
||||
launcherValueArgs,
|
||||
)
|
||||
}
|
||||
private[client] def serverCommand(arguments: Arguments): List[String] =
|
||||
arguments.sbtLaunchJar match {
|
||||
case Some(lj) =>
|
||||
val java =
|
||||
Option(Properties.javaHome).map(javaHome => s"$javaHome/bin/java").getOrElse("java")
|
||||
List(java) ++ arguments.sbtArguments.filterNot(emptyBuildFlags.contains) ++
|
||||
List("-jar", lj, DashDashDetachStdio, DashDashServer)
|
||||
case _ =>
|
||||
List(arguments.sbtScript) ++ arguments.launcherValueArgs ++ arguments.sbtArguments ++
|
||||
List(DashDashDetachStdio, DashDashServer)
|
||||
}
|
||||
private[client] val completions = "--completions"
|
||||
private[client] val noTab = "--no-tab"
|
||||
private[client] val noStdErr = "--no-stderr"
|
||||
@@ -1291,6 +1290,8 @@ object NetworkClient {
|
||||
"--autostart=",
|
||||
"-autostart=",
|
||||
)
|
||||
private[client] val launcherValueEqPrefixes: Seq[String] =
|
||||
launcherValueFlags.toSeq.map(_ + "=")
|
||||
private[client] def parseArgs(args: Array[String]): Arguments = {
|
||||
val defaultSbtScript = if (Properties.isWin) "sbt.bat" else "sbt"
|
||||
var sbtScript = Properties.propOrNone("sbt.script")
|
||||
@@ -1299,10 +1300,32 @@ object NetworkClient {
|
||||
val commandArgs = new mutable.ArrayBuffer[String]
|
||||
val sbtArguments = new mutable.ArrayBuffer[String]
|
||||
val completionArguments = new mutable.ArrayBuffer[String]
|
||||
val launcherValueArgs = new mutable.ArrayBuffer[String]
|
||||
val SysProp = "-D([^=]+)=(.*)".r
|
||||
val sanitized = args.flatMap {
|
||||
case a if a.startsWith("\"") => Array(a)
|
||||
case a => a.split(" ")
|
||||
val sanitized = new mutable.ArrayBuffer[String]
|
||||
val splitFromPrev = new mutable.ArrayBuffer[Boolean]
|
||||
args.foreach {
|
||||
case a if a.startsWith("\"") =>
|
||||
sanitized += a
|
||||
splitFromPrev += false
|
||||
case a =>
|
||||
var first = true
|
||||
a.split(" ").foreach { part =>
|
||||
if (part.nonEmpty) {
|
||||
sanitized += part
|
||||
splitFromPrev += !first
|
||||
first = false
|
||||
}
|
||||
}
|
||||
}
|
||||
def valueFrom(start: Int): (String, Int) = {
|
||||
var last = start
|
||||
val sb = new StringBuilder(sanitized(start))
|
||||
while (last + 1 < sanitized.length && splitFromPrev(last + 1)) {
|
||||
last += 1
|
||||
sb.append(" ").append(sanitized(last))
|
||||
}
|
||||
(sb.toString, last)
|
||||
}
|
||||
var i = 0
|
||||
while (i < sanitized.length) {
|
||||
@@ -1337,7 +1360,20 @@ object NetworkClient {
|
||||
case a if a.startsWith("-autostart=") =>
|
||||
System.setProperty("sbt.server.autostart", a.stripPrefix("-autostart="))
|
||||
case a if launcherValueFlags.contains(a) =>
|
||||
if (i + 1 < sanitized.length) i += 1
|
||||
if (i + 1 < sanitized.length) {
|
||||
launcherValueArgs += a
|
||||
val (value, last) = valueFrom(i + 1)
|
||||
launcherValueArgs += value
|
||||
i = last
|
||||
}
|
||||
case a if launcherValueEqPrefixes.exists(p => a.startsWith(p)) =>
|
||||
val (full, last) = valueFrom(i)
|
||||
i = last
|
||||
val eq = full.indexOf('=')
|
||||
if (eq < full.length - 1) {
|
||||
launcherValueArgs += full.substring(0, eq)
|
||||
launcherValueArgs += full.substring(eq + 1)
|
||||
}
|
||||
case a if launcherNoValueFlags.contains(a) => ()
|
||||
case a if launcherEqPrefixes.exists(p => a.startsWith(p)) => ()
|
||||
case a if a.startsWith("-J") => ()
|
||||
@@ -1364,6 +1400,7 @@ object NetworkClient {
|
||||
sbtScript.getOrElse(defaultSbtScript).replace("%20", " "),
|
||||
bsp,
|
||||
launchJar,
|
||||
launcherValueArgs.toSeq,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import java.nio.file.Files
|
||||
|
||||
import sbt.internal.classpath.ClassLoaderCache
|
||||
import sbt.io.IO
|
||||
import scala.util.Using
|
||||
import verify.BasicTestSuite
|
||||
|
||||
object ClassLoaderCacheTest extends BasicTestSuite:
|
||||
@@ -52,4 +53,31 @@ object ClassLoaderCacheTest extends BasicTestSuite:
|
||||
Predef.assert(cache.get(jarClassPath) == secondLoader)
|
||||
Predef.assert(cache.get(jarClassPath) != initLoader)
|
||||
|
||||
test("Loaders evicted by a newer timestamp should be closed by clear()"):
|
||||
IO.withTemporaryDirectory: dir =>
|
||||
val entry = "leak-test-resource.txt"
|
||||
val jar = dir.toPath.resolve("evicted.jar").toFile
|
||||
Using.resource(new java.util.jar.JarOutputStream(new java.io.FileOutputStream(jar))): out =>
|
||||
out.putNextEntry(new java.util.zip.ZipEntry(entry))
|
||||
out.write("hello".getBytes("UTF-8"))
|
||||
out.closeEntry()
|
||||
|
||||
withCache: cache =>
|
||||
val classPath = jar :: Nil
|
||||
val first = cache.get(classPath)
|
||||
Predef.assert(first.getResource(entry) != null, "jar resource should load before eviction")
|
||||
|
||||
// Bumping the timestamp makes a new Key, and addLoader calls clearExpiredLoaders after
|
||||
// inserting it, so `first`'s entry is evicted from the delegate map here. Once evicted
|
||||
// it is unreachable from the map, so only the `retired` set can still close it.
|
||||
IO.setModifiedTimeOrFalse(jar, System.currentTimeMillis + 5000L)
|
||||
val second = cache.get(classPath)
|
||||
Predef.assert(first != second, "a newer timestamp should produce a new loader")
|
||||
|
||||
cache.clear()
|
||||
Predef.assert(
|
||||
first.getResource(entry) == null,
|
||||
"clear() should have closed the evicted loader, releasing its jar handle"
|
||||
)
|
||||
|
||||
end ClassLoaderCacheTest
|
||||
|
||||
@@ -184,6 +184,7 @@ object NetworkClientParseArgsTest extends BasicTestSuite:
|
||||
assert(!result.sbtArguments.contains("-mem"))
|
||||
assert(!result.sbtArguments.contains("10000"))
|
||||
assert(result.sbtArguments.exists(_.contains("-Dfoo=bar")))
|
||||
assert(result.launcherValueArgs == Seq("-mem", "10000"))
|
||||
assert(result.commandArguments.contains("compile"))
|
||||
assert(result.commandArguments.contains("test"))
|
||||
|
||||
@@ -196,7 +197,71 @@ object NetworkClientParseArgsTest extends BasicTestSuite:
|
||||
assert(!result.sbtArguments.contains("/jdk"))
|
||||
assert(!result.sbtArguments.exists(_.contains("color=never")))
|
||||
assert(result.sbtArguments.exists(_.contains("-Dfoo=bar")))
|
||||
assert(result.launcherValueArgs == Seq("-java-home", "/jdk"))
|
||||
assert(result.commandArguments.contains("compile"))
|
||||
assert(result.commandArguments.size == 1)
|
||||
|
||||
// -- Launcher value flags are captured for a forked server (#9418) --
|
||||
|
||||
test("-java-home /path is captured in launcherValueArgs for propagation"):
|
||||
val result = parse("-java-home", "/path/to/jdk", "compile")
|
||||
assert(result.launcherValueArgs == Seq("-java-home", "/path/to/jdk"))
|
||||
|
||||
test("--java-home=/path is consumed, not forwarded, and captured as flag + value"):
|
||||
val result = parse("--java-home=/path/to/jdk", "compile")
|
||||
assert(!result.sbtArguments.exists(_.contains("java-home")))
|
||||
assert(!result.commandArguments.exists(_.contains("java-home")))
|
||||
assert(result.launcherValueArgs == Seq("--java-home", "/path/to/jdk"))
|
||||
assert(result.commandArguments.contains("compile"))
|
||||
|
||||
test("--java-home=/path does not leak a bare -- into forwarded args"):
|
||||
val result = parse("--java-home=/usr/lib/jvm/java-17", "scalafmtCheckAll")
|
||||
assert(!result.sbtArguments.exists(_.startsWith("--java-home")))
|
||||
assert(result.commandArguments == Seq("scalafmtCheckAll"))
|
||||
|
||||
test("-mem=10000 eq-form is consumed and captured as flag + value"):
|
||||
val result = parse("-mem=10000", "compile")
|
||||
assert(!result.sbtArguments.exists(_.contains("mem")))
|
||||
assert(result.launcherValueArgs == Seq("-mem", "10000"))
|
||||
assert(result.commandArguments.contains("compile"))
|
||||
|
||||
test("--java-home= with an empty value is consumed but not propagated"):
|
||||
val result = parse("--java-home=", "compile")
|
||||
assert(!result.sbtArguments.exists(_.contains("java-home")))
|
||||
assert(result.launcherValueArgs.isEmpty)
|
||||
assert(result.commandArguments == Seq("compile"))
|
||||
|
||||
test("-java-home with a spaced path keeps the path intact"):
|
||||
val result = parse("-java-home", "C:\\Program Files\\Java\\jdk-17", "compile")
|
||||
assert(result.launcherValueArgs == Seq("-java-home", "C:\\Program Files\\Java\\jdk-17"))
|
||||
assert(result.commandArguments == Seq("compile"))
|
||||
|
||||
test("--java-home= with a spaced path keeps the path intact"):
|
||||
val result = parse("--java-home=C:\\Program Files\\Java\\jdk-17", "compile")
|
||||
assert(result.launcherValueArgs == Seq("--java-home", "C:\\Program Files\\Java\\jdk-17"))
|
||||
assert(result.commandArguments == Seq("compile"))
|
||||
|
||||
test("a single arg joining a flag and its value is still split and captured"):
|
||||
val result = parse("-mem 10000", "compile")
|
||||
assert(result.launcherValueArgs == Seq("-mem", "10000"))
|
||||
assert(result.commandArguments == Seq("compile"))
|
||||
|
||||
test("serverCommand propagates -java-home to the forked server before --server"):
|
||||
val args = parse("-java-home", "/opt/jdk17", "compile")
|
||||
val cmd = NetworkClient.serverCommand(args)
|
||||
val jh = cmd.indexOf("-java-home")
|
||||
assert(jh >= 0, cmd.toString)
|
||||
assert(cmd(jh + 1) == "/opt/jdk17", cmd.toString)
|
||||
assert(jh < cmd.indexOf("--server"), cmd.toString)
|
||||
|
||||
test("a value flag with no value is dropped, not propagated as a dangling flag"):
|
||||
val result = parse("-java-home")
|
||||
assert(result.launcherValueArgs.isEmpty)
|
||||
assert(result.commandArguments.isEmpty)
|
||||
|
||||
test("extra whitespace in a value does not leave a stray leading space"):
|
||||
val result = parse("-mem", " 10000", "compile")
|
||||
assert(result.launcherValueArgs == Seq("-mem", "10000"))
|
||||
assert(result.commandArguments == Seq("compile"))
|
||||
|
||||
end NetworkClientParseArgsTest
|
||||
|
||||
@@ -762,6 +762,7 @@ object Defaults extends BuildCommon {
|
||||
case NonFatal(_) => ()
|
||||
}
|
||||
clean.value
|
||||
(ThisBuild / publish / clean).value
|
||||
},
|
||||
scalaCompilerBridgeBin := Def
|
||||
.ifS(Def.task {
|
||||
|
||||
@@ -218,6 +218,7 @@ private[sbt] object Clean {
|
||||
val h = Help.more(CleanFull, cleanFullDetailed)
|
||||
val expunge: State => State =
|
||||
(s: State) =>
|
||||
import UpperStateOps.*
|
||||
val outputDirectory = s
|
||||
.get(BasicKeys.rootOutputDirectory)
|
||||
.getOrElse(sys.error("outputDirectory has not been set"))
|
||||
@@ -225,6 +226,7 @@ private[sbt] object Clean {
|
||||
cacheStore.foreach:
|
||||
case d: DiskActionCacheStore => d.clear()
|
||||
case _ => ()
|
||||
val s2 = s.unsafeRunAggregated(LocalRootProject / clean)
|
||||
IO.delete(outputDirectory.toFile())
|
||||
s
|
||||
Command.command(CleanFull, h)(expunge andThen clearCachesFun)
|
||||
|
||||
@@ -1117,7 +1117,7 @@ private[sbt] object Load {
|
||||
val newProjects = rest ++ discovered ++ projectLevelExtra
|
||||
val newAcc = acc :+ finalRoot
|
||||
val newGenerated = generated ++ generatedConfigClassFiles
|
||||
loadTransitive1(newProjects, newAcc, newGenerated, commonSettings)
|
||||
loadTransitive1(newProjects, newAcc, newGenerated, finalRoot.commonSettings)
|
||||
}
|
||||
|
||||
// Load all config files AND process the project at the root directory, if it exists.
|
||||
|
||||
@@ -10,6 +10,9 @@ package sbt
|
||||
package plugins
|
||||
|
||||
import Def.Setting
|
||||
import Keys.*
|
||||
import sbt.io.IO
|
||||
import sbt.io.syntax.*
|
||||
|
||||
/**
|
||||
* Plugin that enables resolving artifacts via ivy.
|
||||
@@ -29,6 +32,13 @@ object IvyPlugin extends AutoPlugin {
|
||||
|
||||
override lazy val globalSettings: Seq[Setting[?]] =
|
||||
Defaults.globalIvyCore
|
||||
override lazy val buildSettings: Seq[Setting[?]] =
|
||||
Seq(
|
||||
publish / clean := Def.uncached {
|
||||
IO.delete(stagingDirectory.value)
|
||||
IO.delete((ThisBuild / baseDirectory).value / "target" / "sona-bundle")
|
||||
},
|
||||
)
|
||||
override lazy val projectSettings: Seq[Setting[?]] =
|
||||
Classpaths.ivyPublishSettings ++ Classpaths.ivyBaseSettings
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
### Cache-write serialization no longer performs file I/O
|
||||
|
||||
Serializing a task's result for the action cache re-read the size of every
|
||||
referenced file at write time, so a file vanishing between the task completing
|
||||
and the cache write (for example when two overlapping evaluations of the same
|
||||
task race the jar-to-CAS-symlink swap) made an otherwise successful task's
|
||||
cache write throw an intermittent `sjsonnew.SerializationException: error
|
||||
while writing the field outputFiles`. Each stored output reference is now
|
||||
materialized once, before its blob is stored, so the cache write succeeds even
|
||||
if the file vanishes afterwards, and I/O errors on an output file surface
|
||||
upfront at blob storage time rather than mid-serialization.
|
||||
|
||||
This addresses [#9349][i9349].
|
||||
|
||||
[i9349]: https://github.com/sbt/sbt/issues/9349
|
||||
@@ -0,0 +1,13 @@
|
||||
### Every `Def.declareOutput` call registers, including inside loops
|
||||
|
||||
The cached-task macro allocated one slot per syntactic `Def.declareOutput` (or
|
||||
`Def.declareOutputDirectory`) call site, so a call inside a loop or `.map` over a
|
||||
runtime-determined list of files overwrote the same slot on every iteration and
|
||||
only the last file was cached and restored. Declared outputs now accumulate per
|
||||
execution, so a dynamic number of outputs declared from one call site all
|
||||
survive a cache hit. A `declareOutput` in a conditional branch that is not taken
|
||||
no longer contributes a null entry to the task's outputs either.
|
||||
|
||||
This addresses the loop half of [#9462][i9462].
|
||||
|
||||
[i9462]: https://github.com/sbt/sbt/issues/9462
|
||||
@@ -0,0 +1,15 @@
|
||||
### Directories declared with `Def.declareOutputDirectory` are restored on cache hits
|
||||
|
||||
A directory declared as a cached task's output is packaged as a `.sbtdir.zip`
|
||||
sibling of the directory. Deleting the directory leaves the sibling zip behind
|
||||
(an `rm -rf` of the directory or of `classes/` does exactly this), which left
|
||||
the cache convinced everything was in sync: on the next cache hit the task did not re-run, but the
|
||||
directory was never re-extracted either. For sbt's own `compile`, whose classes
|
||||
directory is declared this way, a deleted output directory plus a warm cache
|
||||
meant `run` failed with `ClassNotFoundException` and no recompile. The cache now
|
||||
re-extracts a declared directory when the directory itself is missing, at the
|
||||
cost of a single stat on the warm path.
|
||||
|
||||
This addresses the directory-restoration half of [#9462][i9462].
|
||||
|
||||
[i9462]: https://github.com/sbt/sbt/issues/9462
|
||||
@@ -0,0 +1,11 @@
|
||||
### The thin client honors `-java-home` when it starts a server
|
||||
|
||||
Launcher value flags such as `-java-home` were parsed by the thin client but then
|
||||
dropped: the `=` form (`--java-home=/path`) was forwarded to the server verbatim
|
||||
and rejected as a command (`Not a valid command: --`), and the space form
|
||||
(`-java-home /path`) was silently discarded when the client had to start a server,
|
||||
so that server came up under the default JVM. Both forms are now consumed by the
|
||||
client and re-passed to a server it starts, so the server runs under the requested
|
||||
JVM, including values that contain spaces such as a Windows path
|
||||
(`C:\Program Files\Java\...`). This also applies to the other launcher value flags
|
||||
(`-mem`, `-jvm-debug`, `-sbt-dir`, ...), which were dropped the same way.
|
||||
@@ -12,8 +12,8 @@ object Dependencies {
|
||||
sys.env.get("BUILD_VERSION") orElse sys.props.get("sbt.build.version")
|
||||
|
||||
// sbt modules
|
||||
val ioVersion = nightlyVersion.getOrElse("1.12.1")
|
||||
val zincVersion = nightlyVersion.getOrElse("2.0.2")
|
||||
val ioVersion = nightlyVersion.getOrElse("1.12.2")
|
||||
val zincVersion = nightlyVersion.getOrElse("2.0.3")
|
||||
|
||||
private val sbtIO = "org.scala-sbt" %% "io" % ioVersion
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
name := "clean-sona-staging"
|
||||
@@ -0,0 +1,7 @@
|
||||
$ touch target/sona-staging/dummy.txt
|
||||
$ touch target/sona-bundle/dummy.txt
|
||||
|
||||
> clean
|
||||
|
||||
$ absent target/sona-staging/dummy.txt
|
||||
$ absent target/sona-bundle/dummy.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
@main def main(args: String*) =
|
||||
println("hello from probe")
|
||||
@@ -0,0 +1,53 @@
|
||||
import sbt.internal.util.CacheEventSummary
|
||||
|
||||
val checkCompileHit = taskKey[Unit]("asserts the previous command took cache hits")
|
||||
val delClasses = taskKey[Unit]("deletes the classes directory")
|
||||
val delClassesZip = taskKey[Unit]("deletes the sibling classes.sbtdir.zip")
|
||||
val checkClasses = taskKey[Unit]("asserts .class files exist")
|
||||
val checkNoClasses = taskKey[Unit]("asserts no .class files exist")
|
||||
|
||||
Global / localCacheDirectory := baseDirectory.value / "diskcache"
|
||||
|
||||
// A distinct project id keeps this fixture's output paths from colliding with same-named
|
||||
// sibling fixtures in scripted's shared batch directory.
|
||||
lazy val compileClassesRestore = project
|
||||
.in(file("."))
|
||||
.settings(
|
||||
scalaVersion := "3.8.4"
|
||||
)
|
||||
|
||||
delClasses := Def.uncached {
|
||||
val dir = (Compile / classDirectory).value
|
||||
IO.delete(dir)
|
||||
streams.value.log.info(s"deleted $dir")
|
||||
}
|
||||
|
||||
delClassesZip := Def.uncached {
|
||||
val dir = (Compile / classDirectory).value
|
||||
val zip = new java.io.File(dir.getParentFile, dir.getName + ".sbtdir.zip")
|
||||
streams.value.log.info(s"deleting $zip (exists=${zip.exists})")
|
||||
IO.delete(zip)
|
||||
}
|
||||
|
||||
checkClasses := Def.uncached {
|
||||
val dir = (Compile / classDirectory).value
|
||||
val classes = (dir ** "*.class").get()
|
||||
streams.value.log.info(s"classes under $dir: ${classes.mkString(", ")}")
|
||||
assert(classes.nonEmpty, s"no class files under $dir")
|
||||
}
|
||||
|
||||
checkNoClasses := Def.uncached {
|
||||
val dir = (Compile / classDirectory).value
|
||||
val classes = (dir ** "*.class").get()
|
||||
streams.value.log.info(s"classes under $dir: ${classes.mkString(", ")}")
|
||||
assert(classes.isEmpty, s"unexpected class files under $dir: ${classes.mkString(", ")}")
|
||||
}
|
||||
|
||||
checkCompileHit := Def.uncached {
|
||||
val config = Def.cacheConfiguration.value
|
||||
val prev = config.cacheEventLog.previous match
|
||||
case s: CacheEventSummary.Data => s
|
||||
case _ => sys.error("empty event log")
|
||||
streams.value.log.info(s"prev hitCount=${prev.hitCount} missCount=${prev.missCount}")
|
||||
assert(prev.hitCount >= 1, s"expected cache hits but hitCount=${prev.hitCount}")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Regression for #9462: compile's classes dir (declared via Def.declareOutputDirectory)
|
||||
# must be restored on a cache hit after the classes directory is deleted.
|
||||
> compile
|
||||
> checkClasses
|
||||
> run
|
||||
> delClasses
|
||||
> checkNoClasses
|
||||
> compile
|
||||
> checkCompileHit
|
||||
> checkClasses
|
||||
> run
|
||||
|
||||
# restoration also works when the sibling classes.sbtdir.zip is gone too
|
||||
> delClasses
|
||||
> delClassesZip
|
||||
> compile
|
||||
> checkClasses
|
||||
> run
|
||||
@@ -0,0 +1,60 @@
|
||||
import sbt.internal.util.CacheEventSummary
|
||||
import xsbti.HashedVirtualFileRef
|
||||
|
||||
val declareDir = taskKey[HashedVirtualFileRef]("writes 2 files into a dir and declares the dir")
|
||||
val checkFiles = taskKey[Unit]("asserts both files exist")
|
||||
val checkGone = taskKey[Unit]("asserts the dir has no files")
|
||||
val delDir = taskKey[Unit]("deletes the generated dir")
|
||||
val delZip = taskKey[Unit]("deletes the sibling .sbtdir.zip")
|
||||
val checkHit = taskKey[Unit]("asserts previous command was a pure cache hit")
|
||||
|
||||
Global / localCacheDirectory := baseDirectory.value / "diskcache"
|
||||
|
||||
lazy val declareOutputDirRestore = project.in(file("."))
|
||||
|
||||
declareDir := {
|
||||
val log = streams.value.log
|
||||
val dir = target.value / "gen-dir"
|
||||
IO.createDirectory(dir)
|
||||
IO.write(dir / "a.txt", "contents A")
|
||||
IO.write(dir / "b.txt", "contents B")
|
||||
log.info(s"COMPUTED declareDir (cache miss)")
|
||||
val vf = fileConverter.value.toVirtualFile(dir.toPath)
|
||||
Def.declareOutputDirectory(vf)
|
||||
}
|
||||
|
||||
checkFiles := Def.uncached {
|
||||
val log = streams.value.log
|
||||
val dir = target.value / "gen-dir"
|
||||
val listing = if (dir.exists) (dir ** "*").get().mkString(", ") else "<dir missing>"
|
||||
log.info(s"gen-dir listing: $listing")
|
||||
assert((dir / "a.txt").exists, s"a.txt missing under $dir")
|
||||
assert((dir / "b.txt").exists, s"b.txt missing under $dir")
|
||||
}
|
||||
|
||||
checkGone := Def.uncached {
|
||||
val dir = target.value / "gen-dir"
|
||||
assert(!(dir / "a.txt").exists && !(dir / "b.txt").exists, s"files still present under $dir")
|
||||
}
|
||||
|
||||
delDir := Def.uncached {
|
||||
val dir = target.value / "gen-dir"
|
||||
IO.delete(dir)
|
||||
streams.value.log.info(s"deleted $dir")
|
||||
}
|
||||
|
||||
delZip := Def.uncached {
|
||||
val zip = new java.io.File(target.value, "gen-dir.sbtdir.zip")
|
||||
streams.value.log.info(s"deleting $zip (exists=${zip.exists})")
|
||||
IO.delete(zip)
|
||||
}
|
||||
|
||||
checkHit := Def.uncached {
|
||||
val config = Def.cacheConfiguration.value
|
||||
val prev = config.cacheEventLog.previous match
|
||||
case s: CacheEventSummary.Data => s
|
||||
case _ => sys.error("empty event log")
|
||||
streams.value.log.info(s"prev hitCount=${prev.hitCount} missCount=${prev.missCount}")
|
||||
assert(prev.missCount == 0, s"expected pure hit but missCount=${prev.missCount}")
|
||||
assert(prev.hitCount >= 1, s"expected a hit but hitCount=${prev.hitCount}")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Regression for #9462: a Def.declareOutputDirectory dir must be restored on a cache hit
|
||||
# after the extracted directory is deleted (the sibling .sbtdir.zip survives an rm -rf).
|
||||
> declareDir
|
||||
> checkFiles
|
||||
> delDir
|
||||
> checkGone
|
||||
> declareDir
|
||||
> checkHit
|
||||
> checkFiles
|
||||
|
||||
# restoration also works when the sibling zip itself is gone
|
||||
> delDir
|
||||
> delZip
|
||||
> declareDir
|
||||
> checkHit
|
||||
> checkFiles
|
||||
@@ -0,0 +1,66 @@
|
||||
import sbt.internal.util.CacheEventSummary
|
||||
import xsbti.HashedVirtualFileRef
|
||||
|
||||
val declareLoop = taskKey[Seq[HashedVirtualFileRef]]("declares 3 files via .map over a runtime list")
|
||||
val checkAll = taskKey[Unit]("asserts all 3 files exist")
|
||||
val delFiles = taskKey[Unit]("deletes the 3 files")
|
||||
val checkNone = taskKey[Unit]("asserts none of the 3 files exist")
|
||||
val checkHit = taskKey[Unit]("asserts previous command was a pure cache hit")
|
||||
|
||||
Global / localCacheDirectory := baseDirectory.value / "diskcache"
|
||||
|
||||
lazy val declareOutputLoop = project.in(file("."))
|
||||
|
||||
declareLoop := {
|
||||
val log = streams.value.log
|
||||
val dir = target.value / "gen-multi"
|
||||
IO.createDirectory(dir)
|
||||
val files = List(dir / "a.txt", dir / "b.txt", dir / "c.txt")
|
||||
IO.write(files(0), "AAA")
|
||||
IO.write(files(1), "BBB")
|
||||
IO.write(files(2), "CCC")
|
||||
log.info(s"COMPUTED declareLoop (cache miss)")
|
||||
if (sys.props.contains("never.set.property")) {
|
||||
val ghost = fileConverter.value.toVirtualFile((dir / "never.txt").toPath)
|
||||
val _ = Def.declareOutput(ghost)
|
||||
}
|
||||
files.map { f =>
|
||||
val vf = fileConverter.value.toVirtualFile(f.toPath)
|
||||
Def.declareOutput(vf)
|
||||
}
|
||||
}
|
||||
|
||||
def listing(dir: File): String =
|
||||
if (dir.exists) (dir ** "*").get().mkString(", ") else "<dir missing>"
|
||||
|
||||
checkAll := Def.uncached {
|
||||
val dir = target.value / "gen-multi"
|
||||
streams.value.log.info(s"gen-multi listing: ${listing(dir)}")
|
||||
assert((dir / "a.txt").exists, s"a.txt missing under $dir")
|
||||
assert((dir / "b.txt").exists, s"b.txt missing under $dir")
|
||||
assert((dir / "c.txt").exists, s"c.txt missing under $dir")
|
||||
}
|
||||
|
||||
delFiles := Def.uncached {
|
||||
val dir = target.value / "gen-multi"
|
||||
IO.delete(Seq(dir / "a.txt", dir / "b.txt", dir / "c.txt"))
|
||||
streams.value.log.info(s"deleted files under $dir")
|
||||
}
|
||||
|
||||
checkNone := Def.uncached {
|
||||
val dir = target.value / "gen-multi"
|
||||
assert(
|
||||
!(dir / "a.txt").exists && !(dir / "b.txt").exists && !(dir / "c.txt").exists,
|
||||
s"files still present under $dir"
|
||||
)
|
||||
}
|
||||
|
||||
checkHit := Def.uncached {
|
||||
val config = Def.cacheConfiguration.value
|
||||
val prev = config.cacheEventLog.previous match
|
||||
case s: CacheEventSummary.Data => s
|
||||
case _ => sys.error("empty event log")
|
||||
streams.value.log.info(s"prev hitCount=${prev.hitCount} missCount=${prev.missCount}")
|
||||
assert(prev.missCount == 0, s"expected pure hit but missCount=${prev.missCount}")
|
||||
assert(prev.hitCount >= 1, s"expected a hit but hitCount=${prev.hitCount}")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Regression for #9462: every execution of a Def.declareOutput call site must register,
|
||||
# including calls inside a .map over a runtime-determined list. The task also contains a
|
||||
# declareOutput in a never-taken branch, which must contribute nothing.
|
||||
> declareLoop
|
||||
> checkAll
|
||||
> delFiles
|
||||
> checkNone
|
||||
> declareLoop
|
||||
> checkHit
|
||||
> checkAll
|
||||
@@ -0,0 +1,25 @@
|
||||
// `signTask` stands in for sbt-pgp's signing task: it just writes a fake signature file
|
||||
// and publishes it as an extra artifact with an ".asc" extension.
|
||||
|
||||
useIvy := true
|
||||
|
||||
organization := "com.example"
|
||||
name := "foo"
|
||||
version := "1.0.0"
|
||||
scalaVersion := "2.12.21"
|
||||
autoScalaLibrary := false
|
||||
crossPaths := false
|
||||
Compile / packageDoc / publishArtifact := false
|
||||
Compile / packageSrc / publishArtifact := false
|
||||
publishTo := localStaging.value
|
||||
|
||||
lazy val signTask = taskKey[HashedVirtualFileRef]("Emulates sbt-pgp's signing task")
|
||||
|
||||
signTask := {
|
||||
val conv = fileConverter.value
|
||||
val out = target.value / "foo-1.0.0.jar.asc"
|
||||
IO.write(out, "fake-signature")
|
||||
conv.toVirtualFile(out.toPath)
|
||||
}
|
||||
|
||||
addArtifact(Artifact("foo", "asc", "jar.asc"), signTask)
|
||||
@@ -0,0 +1,5 @@
|
||||
libraryDependencies += Defaults.sbtPluginExtra(
|
||||
"org.scala-sbt" % "sbt-ivy" % sbtVersion.value,
|
||||
sbtVersion.value,
|
||||
scalaVersion.value,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
# useIvy := true forces the Ivy-backed publisher (ConvertResolver), which is what generates
|
||||
# checksums via the ChecksumFriendlyURLResolver shim.
|
||||
> publish
|
||||
|
||||
# ordinary artifacts and their checksums are published as usual
|
||||
$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.jar
|
||||
$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.jar.md5
|
||||
$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.jar.sha1
|
||||
$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.pom
|
||||
$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.pom.md5
|
||||
$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.pom.sha1
|
||||
|
||||
# the .asc signature artifact is published, but must NOT get its own checksum files
|
||||
$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.jar.asc
|
||||
-$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.jar.asc.md5
|
||||
-$ exists target/sona-staging/com/example/foo/1.0.0/foo-1.0.0.jar.asc.sha1
|
||||
@@ -0,0 +1,8 @@
|
||||
import scala.annotation.nowarn
|
||||
|
||||
lazy val check = taskKey[Unit]("")
|
||||
check := {
|
||||
val a = (state.value: @nowarn)
|
||||
val b = state.value
|
||||
println("hi")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import scala.annotation.nowarn
|
||||
|
||||
lazy val check = taskKey[Unit]("")
|
||||
check := {
|
||||
val s = (state.value: @nowarn("msg=unused"))
|
||||
println("hi")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import scala.annotation.nowarn
|
||||
|
||||
lazy val check = taskKey[Unit]("")
|
||||
check := {
|
||||
val s = (state.value: @nowarn)
|
||||
println("hi")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import scala.annotation.nowarn
|
||||
|
||||
lazy val check = taskKey[Unit]("")
|
||||
check := {
|
||||
val s = (state.value: @nowarn("msg=transient key"))
|
||||
println("hi")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Compile / scalacOptions += "-Werror"
|
||||
@@ -0,0 +1,17 @@
|
||||
$ copy-file changes/good.sbt build.sbt
|
||||
|
||||
> about
|
||||
|
||||
$ copy-file changes/bad1.sbt build.sbt
|
||||
|
||||
-> reload
|
||||
|
||||
$ copy-file changes/bad2.sbt build.sbt
|
||||
|
||||
-> reload
|
||||
|
||||
$ copy-file changes/good2.sbt build.sbt
|
||||
|
||||
> reload
|
||||
|
||||
$ copy-file changes/good.sbt build.sbt
|
||||
@@ -6,11 +6,32 @@
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
val check = taskKey[Unit]("Repro for #4947: task at root when extraProjects creates auto root")
|
||||
@transient
|
||||
val check4947 = taskKey[Unit]("")
|
||||
|
||||
@transient
|
||||
val check5661 = taskKey[Unit]("")
|
||||
|
||||
@transient
|
||||
val check9493 = taskKey[Unit]("")
|
||||
|
||||
organization := "com.example"
|
||||
|
||||
val a = project
|
||||
val p = project
|
||||
.settings(
|
||||
name := "p",
|
||||
check := ()
|
||||
// subproject-level task
|
||||
check4947 := {},
|
||||
|
||||
check9493 := {
|
||||
val o = organization.value
|
||||
assert(o == "com.example", s"actual: $o")
|
||||
}
|
||||
)
|
||||
|
||||
LocalProject("mc") / cantTouchThis := "foo"
|
||||
LocalRootProject / check5661 := {
|
||||
val actual = (LocalProject("mc") / cantTouchThis).value
|
||||
assert(actual == "foo", s"actual: $actual")
|
||||
}
|
||||
|
||||
+12
-4
@@ -6,10 +6,18 @@
|
||||
* Licensed under Apache License 2.0 (see LICENSE)
|
||||
*/
|
||||
|
||||
import sbt._, Keys._
|
||||
import sbt.*, Keys.*
|
||||
|
||||
object ExtraPlugin extends AutoPlugin:
|
||||
object autoImport:
|
||||
val cantTouchThis = settingKey[String]("")
|
||||
end autoImport
|
||||
import autoImport.*
|
||||
|
||||
object ExtraPlugin extends AutoPlugin {
|
||||
override def trigger = allRequirements
|
||||
override def extraProjects: Seq[Project] =
|
||||
Seq(Project("z", file("z")).settings(name := "z"))
|
||||
}
|
||||
Seq(Project("mc", file("mc")).settings(
|
||||
name := "mc",
|
||||
cantTouchThis := "can't touch this",
|
||||
))
|
||||
end ExtraPlugin
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
> check
|
||||
> check4947
|
||||
|
||||
> check5661
|
||||
|
||||
> p/check9493
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Hashtable
|
||||
import java.util.concurrent.TimeUnit.NANOSECONDS
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
import scala.collection.mutable.ListBuffer
|
||||
import scala.util.Properties
|
||||
@@ -196,13 +197,31 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A mutable cell holding the suite that is currently running on a thread.
|
||||
*
|
||||
* The cell exists purely so the suite can be released deterministically. `testSuite` below
|
||||
* is an [[InheritableThreadLocal]], so every thread created while a suite is running -- for
|
||||
* example a pooled worker spawned by an async test framework -- receives a copy of the
|
||||
* *reference* to this cell at construction time. `ThreadLocal.remove()` only clears the
|
||||
* calling thread's entry, so those inherited copies would otherwise pin the `TestSuite`, its
|
||||
* buffered events, and through them the test class loader (and its open jar handles) for the
|
||||
* remaining life of the JVM. Clearing the cell severs the reference for the owning thread and
|
||||
* every thread that inherited it at once.
|
||||
*/
|
||||
private final class SuiteRef(initial: Option[TestSuite]) {
|
||||
private val ref = new AtomicReference(initial)
|
||||
def current: Option[TestSuite] = ref.get()
|
||||
def clear(): Unit = ref.set(None)
|
||||
}
|
||||
|
||||
/** The currently running test suite */
|
||||
private val testSuite = new InheritableThreadLocal[Option[TestSuite]] {
|
||||
override def initialValue(): Option[TestSuite] = None
|
||||
private val testSuite = new InheritableThreadLocal[SuiteRef] {
|
||||
override def initialValue(): SuiteRef = new SuiteRef(None)
|
||||
}
|
||||
|
||||
private def withTestSuite[T](f: TestSuite => T): T =
|
||||
testSuite.get().map(f).getOrElse(sys.error("no test suite"))
|
||||
testSuite.get().current.map(f).getOrElse(sys.error("no test suite"))
|
||||
|
||||
/** Creates the output Dir */
|
||||
override def doInit(): Unit = {
|
||||
@@ -212,14 +231,29 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg
|
||||
/**
|
||||
* Starts a new, initially empty Suite with the given name.
|
||||
*/
|
||||
override def startGroup(name: String): Unit = testSuite.set(Some(new TestSuite(name)))
|
||||
override def startGroup(name: String): Unit =
|
||||
testSuite.set(new SuiteRef(Some(new TestSuite(name))))
|
||||
|
||||
/**
|
||||
* Adds all details for the given even to the current suite.
|
||||
*
|
||||
* Events that arrive after the suite has been written are dropped. Test frameworks may call
|
||||
* the event handler from threads they spawned during the run (see `TestFramework.TestRunner`),
|
||||
* and such a thread can report after `writeSuite` has already emitted the XML. Before the
|
||||
* suite was released those late events were appended to an already-written suite, so they
|
||||
* were discarded in practice; dropping them here keeps that outcome without turning every
|
||||
* late event into an error line via `TestFramework.safeForeach`.
|
||||
*/
|
||||
override def testEvent(event: TestEvent): Unit = for (e <- event.detail) {
|
||||
withTestSuite(_.addEvent(e))
|
||||
}
|
||||
override def testEvent(event: TestEvent): Unit =
|
||||
testSuite.get().current match {
|
||||
case Some(suite) => for (e <- event.detail) suite.addEvent(e)
|
||||
case None =>
|
||||
if (logger != null) {
|
||||
logger.debug(
|
||||
s"ignoring ${event.detail.size} test event(s) reported after the suite was written"
|
||||
)
|
||||
} else ()
|
||||
}
|
||||
|
||||
/**
|
||||
* called for each class or equivalent grouping We map one group to one Testsuite, so for each
|
||||
@@ -283,6 +317,13 @@ class JUnitXmlTestsListener(val targetDir: File, legacyTestReport: Boolean, logg
|
||||
}
|
||||
val testSuiteResult = withTestSuite(_.stop())
|
||||
XML.save(file, testSuiteResult, "UTF-8", xmlDecl = true, null)
|
||||
/* Order matters: `clear()` releases the suite for this thread *and* for every thread that
|
||||
* inherited the cell, which `remove()` cannot reach. `remove()` then drops this thread's
|
||||
* own entry. Without the `clear()` the suite -- and through its buffered events the test
|
||||
* class loader with its open jar handles -- would stay reachable from pooled worker threads
|
||||
* for the life of the JVM.
|
||||
*/
|
||||
testSuite.get().clear()
|
||||
testSuite.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,20 @@ trait TestsListener extends TestReportListener {
|
||||
|
||||
/**
|
||||
* Provides the overall `result` of a group of tests (a suite) and test counts for each result type.
|
||||
*
|
||||
* @param throwables
|
||||
* The exceptions thrown by the tests in this suite, as live objects. They are needed as objects
|
||||
* rather than as rendered text because the ClassLoaderLayeringStrategy diagnostic in `Defaults`
|
||||
* inspects them with `isInstanceOf` and walks `getCause` to recognise `NoClassDefFoundError` and
|
||||
* friends.
|
||||
*
|
||||
* RETENTION HAZARD: a `Throwable`'s backtrace references the `Class` objects of every frame, and
|
||||
* a `Class` strongly references its defining class loader. Holding a `SuiteResult` with a
|
||||
* non-empty `throwables` therefore keeps the test class loader -- and every jar handle it has
|
||||
* open -- alive. That is fine for the duration of the test task, which is where these are
|
||||
* consumed, but anything that outlives the task must drop them first. `TestRecap.collect` does
|
||||
* exactly that before stashing a copy on `State.attributes`; see the note on `TestRecap.recapKey`.
|
||||
* On Windows a leaked handle makes the underlying jar undeletable (e.g. by `clearCaches`).
|
||||
*/
|
||||
private[sbt] final class SuiteResult(
|
||||
val result: TestResult,
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
import testing.{ Event as TEvent, OptionalThrowable, Status as TStatus, TestSelector }
|
||||
import util.{ AbstractLogger, Level, ControlEvent, LogEvent }
|
||||
import sbt.protocol.testing.TestResult
|
||||
import sbt.io.IO
|
||||
import verify.BasicTestSuite
|
||||
|
||||
object JUnitXmlTestsListenerSpec extends BasicTestSuite:
|
||||
@@ -102,4 +103,54 @@ object JUnitXmlTestsListenerSpec extends BasicTestSuite:
|
||||
tempDir.listFiles().foreach(_.delete())
|
||||
tempDir.delete()
|
||||
|
||||
test("JUnitXmlTestsListener should release the suite from threads that inherited it"):
|
||||
IO.withTemporaryDirectory: tempDir =>
|
||||
val listener = new JUnitXmlTestsListener(tempDir, false, null)
|
||||
listener.doInit()
|
||||
|
||||
def event(name: String) = new TEvent:
|
||||
def fullyQualifiedName = s"InheritSuite.$name"
|
||||
def duration() = 1L
|
||||
def status = TStatus.Success
|
||||
def fingerprint = null
|
||||
def selector = new TestSelector(name)
|
||||
def throwable = new OptionalThrowable()
|
||||
|
||||
listener.startGroup("InheritSuite")
|
||||
|
||||
// A thread created *while* the suite is set inherits the suite cell, standing in for a
|
||||
// pooled worker spawned by an async test framework during the run.
|
||||
val suiteWritten = new java.util.concurrent.CountDownLatch(1)
|
||||
val childDone = new java.util.concurrent.CountDownLatch(1)
|
||||
val endGroupOutcome = new AtomicReference[Option[Throwable]](None)
|
||||
val testEventOutcome = new AtomicReference[Option[Throwable]](None)
|
||||
val child = new Thread(() =>
|
||||
suiteWritten.await()
|
||||
// The inherited cell must no longer reach a TestSuite, so the strict path fails...
|
||||
endGroupOutcome.set(
|
||||
scala.util.Try(listener.endGroup("InheritSuite", TestResult.Passed)).failed.toOption
|
||||
)
|
||||
// ...while a late event is dropped rather than raised.
|
||||
testEventOutcome.set(
|
||||
scala.util.Try(listener.testEvent(sbt.TestEvent(Seq(event("late"))))).failed.toOption
|
||||
)
|
||||
childDone.countDown()
|
||||
)
|
||||
child.setDaemon(true)
|
||||
child.start()
|
||||
|
||||
listener.testEvent(sbt.TestEvent(Seq(event("testMethod"))))
|
||||
listener.endGroup("InheritSuite", TestResult.Passed)
|
||||
suiteWritten.countDown()
|
||||
assert(childDone.await(30, java.util.concurrent.TimeUnit.SECONDS), "child thread timed out")
|
||||
|
||||
assert(
|
||||
endGroupOutcome.get().isDefined,
|
||||
"a thread that inherited the suite could still reach it after writeSuite"
|
||||
)
|
||||
assert(
|
||||
testEventOutcome.get().isEmpty,
|
||||
s"a late test event should be dropped, but threw: ${testEventOutcome.get()}"
|
||||
)
|
||||
|
||||
end JUnitXmlTestsListenerSpec
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
package sbt.util
|
||||
|
||||
import java.io.{ File, IOException, PrintWriter }
|
||||
import java.io.{ File, PrintWriter }
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.{
|
||||
AtomicMoveNotSupportedException,
|
||||
@@ -33,6 +33,7 @@ import sbt.nio.file.syntax.*
|
||||
import sbt.util.CacheImplicits
|
||||
import scala.reflect.ClassTag
|
||||
import scala.annotation.{ meta, StaticAnnotation }
|
||||
import scala.collection.mutable
|
||||
import scala.util.control.NonFatal
|
||||
import sjsonnew.{ HashWriter, JsonFormat }
|
||||
import sjsonnew.support.murmurhash.Hasher
|
||||
@@ -147,7 +148,7 @@ object ActionCache:
|
||||
result
|
||||
case Left(e) => throw e
|
||||
catch
|
||||
case e: IOException =>
|
||||
case NonFatal(e) =>
|
||||
logger.debug(s"Skipping cache storage due to error: ${e.getMessage}")
|
||||
cacheEventLog.append(ActionCacheEvent.Error)
|
||||
result
|
||||
@@ -374,6 +375,14 @@ object ActionCache:
|
||||
Files.move(staging, destZip, StandardCopyOption.REPLACE_EXISTING)
|
||||
finally Files.deleteIfExists(staging)
|
||||
|
||||
/** Appends a declared output; called from code generated by the cached-task macro. */
|
||||
def registerOutput(
|
||||
vf: VirtualFile,
|
||||
outputs: mutable.ListBuffer[VirtualFile],
|
||||
): VirtualFile =
|
||||
outputs += vf
|
||||
vf
|
||||
|
||||
def packageDirectory(
|
||||
dir: VirtualFileRef,
|
||||
conv: FileConverter,
|
||||
|
||||
@@ -65,16 +65,20 @@ end ActionCacheStore
|
||||
|
||||
trait AbstractActionCacheStore extends ActionCacheStore:
|
||||
def putBlobsIfNeeded(blobs: Seq[VirtualFile]): Seq[HashedVirtualFileRef] =
|
||||
// Read each blob's hash and size once, up front, and return only these plain value refs:
|
||||
// serializing the ActionResult afterwards does no file I/O, and nothing re-stats a blob
|
||||
// after its CAS entry is written.
|
||||
val materialized: Seq[(VirtualFile, HashedVirtualFileRef)] = blobs.map: blob =>
|
||||
blob -> HashedVirtualFileRef.of(blob.id, blob.contentHashStr, blob.sizeBytes)
|
||||
val found = findBlobs(blobs).toSet
|
||||
val missing = blobs.flatMap: blob =>
|
||||
val ref: HashedVirtualFileRef = blob
|
||||
if found.contains(ref) then None
|
||||
else Some(blob)
|
||||
val combined = putBlobs(missing).toSet ++ found
|
||||
blobs.flatMap: blob =>
|
||||
materialized.flatMap: (blob, plain) =>
|
||||
val ref: HashedVirtualFileRef = blob
|
||||
if combined.contains(ref) then Some(ref)
|
||||
else None
|
||||
if combined.contains(ref) then Some(plain) else None
|
||||
|
||||
def notFound: Throwable =
|
||||
new RuntimeException("not found")
|
||||
@@ -285,14 +289,17 @@ case class DiskActionCacheStore(base: Path, converter: FileConverter)
|
||||
|
||||
override def syncBlobs(refs: Seq[HashedVirtualFileRef], outputDirectory: Path): Seq[Path] =
|
||||
refs.flatMap: r =>
|
||||
try
|
||||
val casFile = toCasFile(Digest(r))
|
||||
if isCompleteBlob(casFile, Digest(r)) then
|
||||
// println(s"syncBlobs: $casFile exists for $r")
|
||||
Some(syncFile(r, casFile, outputDirectory))
|
||||
else None
|
||||
// Digest(r) can throw NoSuchFileException
|
||||
catch case _: NoSuchFileException => None
|
||||
// Only the blob-availability lookup may swallow NoSuchFileException (Digest(r) can throw it):
|
||||
// an absent blob is a cache miss for that entry. A write failure from syncFile, however, must
|
||||
// propagate so the caller can degrade to the onsite task (sbt/sbt#8890) instead of silently
|
||||
// leaving the output tree incomplete (sbt/sbt#9349).
|
||||
val casFileOpt =
|
||||
try
|
||||
val digest = Digest(r)
|
||||
val casFile = toCasFile(digest)
|
||||
if isCompleteBlob(casFile, digest) then Some(casFile) else None
|
||||
catch case _: NoSuchFileException => None
|
||||
casFileOpt.map(syncFile(r, _, outputDirectory))
|
||||
|
||||
def syncFile(ref: HashedVirtualFileRef, casFile: Path, outputDirectory: Path): Path =
|
||||
val d = Digest(ref)
|
||||
@@ -340,7 +347,9 @@ case class DiskActionCacheStore(base: Path, converter: FileConverter)
|
||||
try
|
||||
// `!symlinkSupported` prevents unnecessary deletion of files and then copying them again
|
||||
// in #writeFileAndNotify on machines that don't support symlinks.
|
||||
if Digest.sameDigest(p, d) && (!symlinkSupported.get() || Files.isSymbolicLink(p)) then p
|
||||
if Digest.sameDigest(p, d) && (!symlinkSupported.get() || Files.isSymbolicLink(p)) then
|
||||
afterFileUpToDate(ref, p, outputDirectory)
|
||||
p
|
||||
else
|
||||
// println(s"- syncFile: $p has different digest")
|
||||
IO.delete(p.toFile())
|
||||
@@ -358,6 +367,16 @@ case class DiskActionCacheStore(base: Path, converter: FileConverter)
|
||||
if path.toString().endsWith(ActionCache.dirZipExt) then unpackageDirZip(path, outputDirectory)
|
||||
else ()
|
||||
|
||||
/** Re-extract a dirzip whose extracted directory is missing: one stat on the warm path. */
|
||||
private def afterFileUpToDate(
|
||||
ref: HashedVirtualFileRef,
|
||||
path: Path,
|
||||
outputDirectory: Path
|
||||
): Unit =
|
||||
if path.toString().endsWith(ActionCache.dirZipExt) then
|
||||
val dirPath = Paths.get(path.toString.dropRight(ActionCache.dirZipExt.size))
|
||||
if !Files.isDirectory(dirPath) then Util.ignoreResult(unpackageDirZip(path, outputDirectory))
|
||||
|
||||
/**
|
||||
* Given a dirzip, unzip it in a temp directory, and sync each items to the outputDirectory.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package sbt.util
|
||||
|
||||
import java.io.{ IOException, InputStream }
|
||||
import java.io.{ ByteArrayInputStream, IOException, InputStream }
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.{ Files, NoSuchFileException, Path, Paths }
|
||||
import java.util.Optional
|
||||
@@ -25,6 +25,8 @@ import xsbti.{
|
||||
import ActionCache.InternalActionResult
|
||||
|
||||
object ActionCacheTest extends BasicTestSuite:
|
||||
final case class Unserializable(n: Int)
|
||||
|
||||
val tags = CacheLevelTag.all.toList
|
||||
|
||||
test("findMissingFile extracts the path from a wrapped NoSuchFileException"):
|
||||
@@ -88,6 +90,30 @@ object ActionCacheTest extends BasicTestSuite:
|
||||
index += 1
|
||||
b
|
||||
|
||||
test("Disk cache re-extracts a dirzip whose directory was deleted"):
|
||||
withDiskCache: cache =>
|
||||
IO.withTemporaryDirectory: tempDir =>
|
||||
val outputDirectory = tempDir.toPath()
|
||||
val dir = tempDir / "gen-dir"
|
||||
IO.write(dir / "a.txt", "contents A")
|
||||
IO.write(dir / "b.txt", "contents B")
|
||||
val zipVf = ActionCache.packageDirectory(
|
||||
binaryFileConverter.toVirtualFile(dir.toPath()),
|
||||
binaryFileConverter,
|
||||
outputDirectory,
|
||||
)
|
||||
val refs = cache.putBlobs(Seq(zipVf))
|
||||
assert(refs.size == 1)
|
||||
|
||||
cache.syncBlobs(refs, outputDirectory)
|
||||
assert((dir / "a.txt").exists && (dir / "b.txt").exists)
|
||||
|
||||
IO.delete(dir)
|
||||
assert(!dir.exists)
|
||||
cache.syncBlobs(refs, outputDirectory)
|
||||
assert((dir / "a.txt").exists, "a.txt not re-extracted after the directory was deleted")
|
||||
assert((dir / "b.txt").exists, "b.txt not re-extracted after the directory was deleted")
|
||||
|
||||
test("In-memory cache can hold action value"):
|
||||
withInMemoryCache(testActionCacheBasic)
|
||||
|
||||
@@ -324,6 +350,108 @@ object ActionCacheTest extends BasicTestSuite:
|
||||
assert(v2 == 2)
|
||||
assert(called == 1, s"expected a success cache hit after the cure (called=$called)")
|
||||
|
||||
test("A file vanishing after its blob is stored no longer breaks the cache write"):
|
||||
withDiskCache: cache =>
|
||||
import sjsonnew.BasicJsonProtocol.*
|
||||
var called = 0
|
||||
IO.withTemporaryDirectory: tempDir =>
|
||||
val config = getCacheConfig(cache, tempDir)
|
||||
val goodHash = Digest.sha256Hash("hello".getBytes(StandardCharsets.UTF_8)).contentHashStr
|
||||
val casFile = cache.toCasFile(Digest(s"$goodHash/5"))
|
||||
// Models the reported race: the backing file vanishes the moment its blob reaches the
|
||||
// CAS (the concurrent winner's syncFile swap), so any later stat throws.
|
||||
val vanishing = new xsbti.BasicVirtualFileRef(s"$tempDir/out.jar") with VirtualFile:
|
||||
private def maybeThrow[A](a: A): A =
|
||||
if Files.exists(casFile) then throw new NoSuchFileException(id) else a
|
||||
override def contentHash: Long = 0L
|
||||
override def sizeBytes: Long = maybeThrow(5L)
|
||||
override def contentHashStr: String = maybeThrow(goodHash)
|
||||
override def input: java.io.InputStream =
|
||||
new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))
|
||||
val action: ((Int, Int)) => InternalActionResult[Int] = { (a, b) =>
|
||||
called += 1
|
||||
InternalActionResult(a + b, Seq(vanishing))
|
||||
}
|
||||
val v1 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v1 == 2)
|
||||
assert(called == 1)
|
||||
val v2 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v2 == 2)
|
||||
assert(called == 1, s"expected a cache hit: the write must have succeeded (called=$called)")
|
||||
|
||||
test("A file already missing at the cache write degrades to an uncached task"):
|
||||
withDiskCache: cache =>
|
||||
import sjsonnew.BasicJsonProtocol.*
|
||||
var called = 0
|
||||
IO.withTemporaryDirectory: tempDir =>
|
||||
val config = getCacheConfig(cache, tempDir)
|
||||
val gone = new xsbti.BasicVirtualFileRef(s"$tempDir/out.jar") with VirtualFile:
|
||||
override def contentHash: Long = throw new NoSuchFileException(id)
|
||||
override def sizeBytes: Long = throw new NoSuchFileException(id)
|
||||
override def contentHashStr: String = throw new NoSuchFileException(id)
|
||||
override def input: java.io.InputStream = throw new NoSuchFileException(id)
|
||||
val action: ((Int, Int)) => InternalActionResult[Int] = { (a, b) =>
|
||||
called += 1
|
||||
InternalActionResult(a + b, Seq(gone))
|
||||
}
|
||||
val v1 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v1 == 2)
|
||||
assert(called == 1)
|
||||
val v2 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v2 == 2)
|
||||
assert(called == 2, "a degraded cache write must mean a cache miss on the next run")
|
||||
|
||||
test("put materializes output refs so serialization does no file I/O"):
|
||||
withDiskCache: cache =>
|
||||
IO.withTemporaryDirectory: tempDir =>
|
||||
@volatile var vanished = false
|
||||
val goodHash = Digest.sha256Hash("hello".getBytes(StandardCharsets.UTF_8)).contentHashStr
|
||||
val ref = new xsbti.BasicVirtualFileRef(s"$tempDir/out.jar") with VirtualFile:
|
||||
private def maybeThrow[A](a: A): A =
|
||||
if vanished then throw new NoSuchFileException(id) else a
|
||||
override def contentHash: Long = 0L
|
||||
override def sizeBytes: Long = maybeThrow(5L)
|
||||
override def contentHashStr: String = maybeThrow(goodHash)
|
||||
override def input: java.io.InputStream =
|
||||
new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))
|
||||
val stored =
|
||||
cache.put(UpdateActionResultRequest(Digest.dummy(42L), Vector(ref), exitCode = 0)) match
|
||||
case Right(r) => r.outputFiles.head
|
||||
case Left(e) => throw new AssertionError(s"put failed: $e", e)
|
||||
vanished = true
|
||||
assert(stored.id == ref.id)
|
||||
assert(
|
||||
stored.contentHashStr == goodHash && stored.sizeBytes == 5L,
|
||||
"stored ref must not re-stat the file"
|
||||
)
|
||||
|
||||
test("A successful task whose value fails to serialize returns it uncached"):
|
||||
withDiskCache: cache =>
|
||||
var called = 0
|
||||
// Pins the value-serialization leg of the NonFatal recovery introduced in #9488, which
|
||||
// had no test: a codec that always throws, standing in for any serialization failure
|
||||
// during the cache write of a succeeded task.
|
||||
given sjsonnew.JsonFormat[Unserializable] = new sjsonnew.JsonFormat[Unserializable]:
|
||||
override def write[J](obj: Unserializable, builder: sjsonnew.Builder[J]): Unit =
|
||||
sjsonnew.serializationError("Unserializable is unserializable")
|
||||
override def read[J](
|
||||
jsOpt: Option[J],
|
||||
unbuilder: sjsonnew.Unbuilder[J]
|
||||
): Unserializable = Unserializable(0)
|
||||
import sjsonnew.BasicJsonProtocol.*
|
||||
val action: ((Int, Int)) => InternalActionResult[Unserializable] = { (a, b) =>
|
||||
called += 1
|
||||
InternalActionResult(Unserializable(a + b), Nil)
|
||||
}
|
||||
IO.withTemporaryDirectory: tempDir =>
|
||||
val config = getCacheConfig(cache, tempDir)
|
||||
val v1 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v1 == Unserializable(2))
|
||||
assert(called == 1)
|
||||
val v2 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v2 == Unserializable(2))
|
||||
assert(called == 2, "a degraded cache write must mean a cache miss on the next run")
|
||||
|
||||
test("Cache falls back to recompute when syncBlobs throws FileNotFoundException"):
|
||||
withDiskCache(testSyncBlobsThrowsFallback)
|
||||
|
||||
@@ -444,6 +572,90 @@ object ActionCacheTest extends BasicTestSuite:
|
||||
assert(Files.size(zipPath) > 0L)
|
||||
finally pool.shutdown()
|
||||
|
||||
// See https://github.com/sbt/sbt/issues/9349. With a warm cache, deleting the output tree
|
||||
// out-of-band must not break the next build: restore recreates the missing parent directories.
|
||||
test("Restore recreates a deleted output directory (direct blob)"):
|
||||
IO.withTemporaryDirectory: cacheDir =>
|
||||
IO.withTemporaryDirectory: outDir =>
|
||||
val conv = fileConverter
|
||||
val cache = DiskActionCacheStore(cacheDir.toPath, conv)
|
||||
val nested = outDir.toPath.resolve("classes/pkg/A.txt")
|
||||
val blob = StringVirtualFile1(nested.toString, "compiled")
|
||||
val refs = cache.putBlobs(Seq(blob))
|
||||
cache.syncBlobs(refs, outDir.toPath)
|
||||
assert(Files.exists(nested), "first sync should create the file")
|
||||
IO.delete(outDir.toPath.resolve("classes").toFile())
|
||||
assert(!Files.exists(nested.getParent))
|
||||
cache.syncBlobs(refs, outDir.toPath)
|
||||
assert(Files.exists(nested), "restore must recreate the deleted parent + file")
|
||||
|
||||
test("Restore recreates a deleted output directory on a cache hit (dirzip)"):
|
||||
import sjsonnew.BasicJsonProtocol.*
|
||||
IO.withTemporaryDirectory: cacheDir =>
|
||||
IO.withTemporaryDirectory: outDir =>
|
||||
val conv = binaryConverter
|
||||
val cache = DiskActionCacheStore(cacheDir.toPath, conv)
|
||||
val classesDir = outDir.toPath.resolve("classes")
|
||||
val classFile = classesDir.resolve("pkg/A.class")
|
||||
var called = 0
|
||||
val action: Unit => InternalActionResult[Int] = { _ =>
|
||||
called += 1
|
||||
Files.createDirectories(classFile.getParent)
|
||||
Files.writeString(classFile, "compiled")
|
||||
val dirzip =
|
||||
ActionCache.packageDirectory(
|
||||
VirtualFileRef.of(classesDir.toString),
|
||||
conv,
|
||||
outDir.toPath
|
||||
)
|
||||
InternalActionResult(1, Seq(dirzip))
|
||||
}
|
||||
val config = getCacheConfig(cache, outDir, converter = conv)
|
||||
val v1 = ActionCache.cache((), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v1 == 1)
|
||||
assert(called == 1)
|
||||
assert(Files.exists(classFile))
|
||||
IO.delete(outDir.toPath.toFile())
|
||||
assert(!Files.exists(classesDir))
|
||||
val v2 = ActionCache.cache((), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v2 == 1)
|
||||
assert(
|
||||
Files.exists(classFile),
|
||||
s"class file must be restored after output dir deletion (called=$called)"
|
||||
)
|
||||
|
||||
// A genuinely broken restore (syncFile write fails) must degrade to the onsite task rather than
|
||||
// being silently swallowed by syncBlobs, which would report a cache hit with incomplete outputs.
|
||||
test("Restore degrades to onsite recompute when materialising throws NoSuchFileException"):
|
||||
testBrokenRestoreDegrades(r => new NoSuchFileException(r.toString))
|
||||
|
||||
test("Restore degrades to onsite recompute when materialising throws a non-IOException"):
|
||||
testBrokenRestoreDegrades(_ => new RuntimeException("disk exploded"))
|
||||
|
||||
def testBrokenRestoreDegrades(mkError: HashedVirtualFileRef => Throwable): Unit =
|
||||
import sjsonnew.BasicJsonProtocol.*
|
||||
IO.withTemporaryDirectory: cacheDir =>
|
||||
IO.withTemporaryDirectory: outDir =>
|
||||
val broken = new DiskActionCacheStore(cacheDir.toPath, fileConverter):
|
||||
override def syncFile(
|
||||
ref: HashedVirtualFileRef,
|
||||
casFile: Path,
|
||||
outputDirectory: Path,
|
||||
): Path = throw mkError(ref)
|
||||
var called = 0
|
||||
val action: ((Int, Int)) => InternalActionResult[Int] = { (a, b) =>
|
||||
called += 1
|
||||
val out = StringVirtualFile1(s"$outDir/a.txt", (a + b).toString)
|
||||
InternalActionResult(a + b, Seq(out))
|
||||
}
|
||||
val config = getCacheConfig(broken, outDir)
|
||||
val v1 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v1 == 2)
|
||||
assert(called == 1)
|
||||
val v2 = ActionCache.cache((1, 1), Digest.zero, Digest.zero, tags, config)(action)
|
||||
assert(v2 == 2)
|
||||
assert(called == 2, s"broken restore must degrade to onsite recompute (called=$called)")
|
||||
|
||||
test("Changing cacheVersion invalidates the cache"):
|
||||
withDiskCache(testCacheVersionInvalidation)
|
||||
|
||||
@@ -492,6 +704,7 @@ object ActionCacheTest extends BasicTestSuite:
|
||||
cache: ActionCacheStore,
|
||||
outputDir: File,
|
||||
cacheVersion: Long = 0L,
|
||||
converter: FileConverter = fileConverter,
|
||||
): BuildWideCacheConfiguration =
|
||||
val logger = new Logger:
|
||||
override def trace(t: => Throwable): Unit = ()
|
||||
@@ -500,16 +713,45 @@ object ActionCacheTest extends BasicTestSuite:
|
||||
BuildWideCacheConfiguration(
|
||||
cache,
|
||||
outputDir.toPath(),
|
||||
fileConverter,
|
||||
converter,
|
||||
logger,
|
||||
CacheEventLog(),
|
||||
CacheImplicits.defaultLocalDigestCacheByteSize,
|
||||
cacheVersion,
|
||||
)
|
||||
|
||||
// The String-based fileConverter mangles binary blobs (zips).
|
||||
def binaryFileConverter = new FileConverter:
|
||||
override def toPath(ref: VirtualFileRef): Path = Paths.get(ref.id)
|
||||
override def toVirtualFile(path: Path): VirtualFile =
|
||||
val bytes =
|
||||
if Files.isRegularFile(path) then Files.readAllBytes(path) else Array.empty[Byte]
|
||||
new xsbti.BasicVirtualFileRef(path.toString) with VirtualFile:
|
||||
override def contentHash: Long = sbt.util.HashUtil.xxhash64(bytes)
|
||||
override def sizeBytes: Long = bytes.length.toLong
|
||||
override def contentHashStr: String = Digest.sha256Hash(bytes).contentHashStr
|
||||
override def input: InputStream = new ByteArrayInputStream(bytes)
|
||||
|
||||
def fileConverter = new FileConverter:
|
||||
override def toPath(ref: VirtualFileRef): Path = Paths.get(ref.id)
|
||||
override def toVirtualFile(path: Path): VirtualFile =
|
||||
val content = if Files.isRegularFile(path) then new String(Files.readAllBytes(path)) else ""
|
||||
StringVirtualFile1(path.toString, content)
|
||||
|
||||
// A converter whose VirtualFiles read raw bytes off disk, so binary blobs (e.g. dirzips) survive
|
||||
// the put/sync round-trip instead of being mangled by a String round-trip.
|
||||
def binaryConverter = new FileConverter:
|
||||
override def toPath(ref: VirtualFileRef): Path = Paths.get(ref.id)
|
||||
override def toVirtualFile(path: Path): VirtualFile = DiskVirtualFile(path.toString)
|
||||
|
||||
final class DiskVirtualFile(path: String)
|
||||
extends xsbti.BasicVirtualFileRef(path)
|
||||
with VirtualFile:
|
||||
private def bytes: Array[Byte] =
|
||||
if Files.isRegularFile(Paths.get(path)) then Files.readAllBytes(Paths.get(path))
|
||||
else Array.emptyByteArray
|
||||
override def contentHash: Long = HashUtil.xxhash64(bytes)
|
||||
override def sizeBytes: Long = bytes.length.toLong
|
||||
override def contentHashStr: String = Digest.sha256Hash(bytes).contentHashStr
|
||||
override def input: InputStream = new java.io.ByteArrayInputStream(bytes)
|
||||
end ActionCacheTest
|
||||
|
||||
Reference in New Issue
Block a user