mirror of https://github.com/sbt/sbt.git
Merge 0085a98614 into b3b1a3477e
This commit is contained in:
commit
6db0845044
|
|
@ -254,7 +254,13 @@ $AliasCommand name=
|
|||
def continuousBriefHelp: (String, String) =
|
||||
(ContinuousExecutePrefix + " <command>", continuousDetail)
|
||||
def ClearCaches: String = "clearCaches"
|
||||
def ClearCachesDetailed: String = "Clears sbt's internal caches."
|
||||
def ClearCachesDetailed: String =
|
||||
"Clears sbt's internal in-memory caches (compiler cache, classloader cache, and file-cache stores), " +
|
||||
"deletes the contents of the local disk action cache (its cas/ and ac/ directories), " +
|
||||
"and removes action-cache references under the root output directory: " +
|
||||
"the materialized task-value files in target/out/value and any symbolic links pointing into the deleted cache. " +
|
||||
"Paths matching cleanKeepFiles or cleanKeepGlobs are preserved. " +
|
||||
"Unlike cleanFull, build outputs, the command history (target/out/.history), and the sbt boot directory are not deleted."
|
||||
|
||||
val CleanFull: String = "cleanFull"
|
||||
def cleanFullDetailed: String = "Clears sbt's local caches."
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import sbt.nio.file.*
|
|||
import sbt.nio.file.syntax.pathToPathOps
|
||||
import sbt.nio.file.Glob.GlobOps
|
||||
import sbt.util.{ DiskActionCacheStore, Level }
|
||||
import sbt.internal.io.Retry
|
||||
import sbt.internal.util.complete.SizeParser
|
||||
import sjsonnew.JsonFormat
|
||||
import xsbti.{ PathBasedFile, VirtualFileRef }
|
||||
|
|
@ -205,10 +206,101 @@ private[sbt] object Clean {
|
|||
s.get(Keys.cacheStoreFactoryFactory).foreach(_.close())
|
||||
s.put(Keys.cacheStoreFactoryFactory, InMemoryCacheStore.factory(size))
|
||||
|
||||
private def actionCacheKeepFilter(s: State): Path => Boolean =
|
||||
val extracted = Project.extract(s)
|
||||
val refs = extracted.structure.allProjectRefs
|
||||
val keepFiles =
|
||||
refs.flatMap(ref => extracted.getOpt(ref / cleanKeepFiles).getOrElse(Nil)).distinct
|
||||
val keepGlobs =
|
||||
refs.flatMap(ref => extracted.getOpt(ref / cleanKeepGlobs).getOrElse(Nil)).distinct
|
||||
val globs = keepFiles.map {
|
||||
case f if f.isDirectory => Glob(f, AnyPath)
|
||||
case f => f.toPath.toGlob
|
||||
} ++ keepGlobs
|
||||
(p: Path) => globs.exists(_.matches(p))
|
||||
|
||||
private def deleteStoreReferences(
|
||||
dir: Path,
|
||||
bases: Seq[Path],
|
||||
exclude: Path => Boolean,
|
||||
delete: Path => Unit
|
||||
): Unit = {
|
||||
def loop(d: Path): Unit =
|
||||
FileTreeView.default
|
||||
.list(Glob(d, AnyPath))
|
||||
.foreach {
|
||||
// FileTreeView reports all-false attributes for dangling symlinks, so symlink
|
||||
// detection must not rely on attrs.isSymbolicLink.
|
||||
case (link, _) if Files.isSymbolicLink(link) =>
|
||||
try {
|
||||
val raw = Files.readSymbolicLink(link)
|
||||
val resolved =
|
||||
(if (raw.isAbsolute) raw
|
||||
else Option(link.getParent).fold(raw)(_.resolve(raw))).normalize()
|
||||
if (bases.exists(resolved.startsWith(_)) && !exclude(link)) delete(link)
|
||||
} catch { case _: IOException => () }
|
||||
case (subdir, attrs) if attrs.isDirectory => loop(subdir)
|
||||
case _ => ()
|
||||
}
|
||||
loop(dir)
|
||||
}
|
||||
|
||||
private val clearActionCacheStores: State => State = (s: State) => {
|
||||
val stores = s.get(BasicKeys.cacheStores).getOrElse(Nil).collect {
|
||||
case d: DiskActionCacheStore => d
|
||||
}
|
||||
val bases = stores.map(_.base.toAbsolutePath.normalize).distinct
|
||||
val exclude = actionCacheKeepFilter(s)
|
||||
val failed = scala.collection.mutable.ListBuffer.empty[(Path, IOException)]
|
||||
/* Deletion failures are collected and reported rather than swallowed. On
|
||||
* Windows a delete can transiently fail with AccessDeniedException while a
|
||||
* handle is briefly held, so retry (as DiskActionCacheStore#syncFile does for
|
||||
* writes). Directories left non-empty by cleanKeepFiles / cleanKeepGlobs are
|
||||
* expected to survive.
|
||||
*/
|
||||
val delete: Path => Unit = path => {
|
||||
try {
|
||||
s.log.debug(s"clearCaches -- deleting $path")
|
||||
Retry.io(Files.deleteIfExists(path), classOf[DirectoryNotEmptyException])
|
||||
()
|
||||
} catch {
|
||||
case _: DirectoryNotEmptyException => ()
|
||||
case e: IOException => failed += (path -> e)
|
||||
}
|
||||
}
|
||||
/* Delete the target/out references (symlinks) before the cas blobs they point at. */
|
||||
s.get(BasicKeys.rootOutputDirectory).foreach { rootOut =>
|
||||
val valueDir = rootOut.resolve("value")
|
||||
if (Files.exists(valueDir)) {
|
||||
deleteContents(valueDir, exclude, FileTreeView.default, delete)
|
||||
delete(valueDir)
|
||||
}
|
||||
if (Files.exists(rootOut) && bases.nonEmpty)
|
||||
deleteStoreReferences(rootOut, bases, exclude, delete)
|
||||
}
|
||||
bases.filter(Files.exists(_)).foreach { base =>
|
||||
deleteContents(base, exclude, FileTreeView.default, delete)
|
||||
}
|
||||
failed.foreach { case (p, e) => s.log.warn(s"clearCaches failed to delete $p: $e") }
|
||||
if (bases.nonEmpty)
|
||||
if (failed.isEmpty)
|
||||
s.log.info(s"cleared action cache store(s): ${bases.mkString(", ")}")
|
||||
else
|
||||
s.log.warn(
|
||||
s"partially cleared action cache store(s): ${bases.mkString(", ")} " +
|
||||
s"(${failed.size} path(s) could not be deleted)"
|
||||
)
|
||||
s
|
||||
}
|
||||
|
||||
/* Reset the in-memory caches before deleting the on-disk stores so that
|
||||
* files the old caches may still reference (e.g. jars held open by cached
|
||||
* classloaders) are released before their deletion is attempted. */
|
||||
private val clearCachesFun: State => State =
|
||||
registerCompilerCache
|
||||
.andThen(_.initializeClassLoaderCache)
|
||||
.andThen(addCacheStoreFactoryFactory)
|
||||
.andThen(clearActionCacheStores)
|
||||
|
||||
def clearCaches: Command =
|
||||
val help = Help.more(ClearCaches, ClearCachesDetailed)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import sbt.internal.util.StringVirtualFile1
|
||||
import sjsonnew.BasicJsonProtocol.*
|
||||
import sbt.nio.file.{ Glob, RecursiveGlob }
|
||||
|
||||
val pure1 = taskKey[Unit]("")
|
||||
val recordCacheBaseline = taskKey[Unit]("")
|
||||
val checkCacheNonEmpty = taskKey[Unit]("")
|
||||
val checkCacheCleared = taskKey[Unit]("")
|
||||
val checkNoStoreLinks = taskKey[Unit]("")
|
||||
|
||||
Global / localCacheDirectory := baseDirectory.value / "diskcache"
|
||||
Global / cleanKeepGlobs += Glob(baseDirectory.value / "diskcache" / "keep", RecursiveGlob)
|
||||
|
||||
def storeEntries(base: File): Set[String] =
|
||||
for {
|
||||
dir <- Set("cas", "ac")
|
||||
file <- Option((base / dir).listFiles).fold(Set.empty[String])(_.map(_.getName).toSet)
|
||||
} yield s"$dir/$file"
|
||||
|
||||
def baselineFile(base: File): File = base / "cache-baseline.txt"
|
||||
|
||||
pure1 := {
|
||||
val output = StringVirtualFile1("${OUT}/a.txt", "foo")
|
||||
Def.declareOutput(output)
|
||||
()
|
||||
}
|
||||
|
||||
/* In batch scripted mode all tests in a group share one sandbox and one
|
||||
* server JVM, and store entries left behind by a previous test may be
|
||||
* pinned by pre-existing classloader/jar-handle leaks that make them
|
||||
* undeletable on Windows (tracked separately; see
|
||||
* .ai/2026-07/2026-07-20-0320-test-classloader-jar-handle-leaks-report.md).
|
||||
* This test therefore only verifies the action-cache entries it creates
|
||||
* itself: everything present at baseline time is out of scope. */
|
||||
recordCacheBaseline := Def.uncached {
|
||||
val entries = storeEntries(baseDirectory.value / "diskcache")
|
||||
IO.writeLines(baselineFile(baseDirectory.value), entries.toList.sorted)
|
||||
}
|
||||
|
||||
checkCacheNonEmpty := Def.uncached {
|
||||
val baseline = IO.readLines(baselineFile(baseDirectory.value)).toSet
|
||||
val added = storeEntries(baseDirectory.value / "diskcache") -- baseline
|
||||
assert(added.nonEmpty, "no new action cache entries were created since the baseline")
|
||||
}
|
||||
|
||||
checkCacheCleared := Def.uncached {
|
||||
def leftover: Set[String] = {
|
||||
val baseline = IO.readLines(baselineFile(baseDirectory.value)).toSet
|
||||
storeEntries(baseDirectory.value / "diskcache") -- baseline
|
||||
}
|
||||
/* A deleted entry can linger in a directory listing for a moment on
|
||||
* Windows (delete-pending state), so poll briefly before asserting. */
|
||||
def eventuallyCleared: Boolean =
|
||||
leftover.isEmpty || (1 to 10).exists { _ =>
|
||||
Thread.sleep(100)
|
||||
leftover.isEmpty
|
||||
}
|
||||
assert(eventuallyCleared, s"entries created by this test survived clearCaches: ${leftover.mkString(", ")}")
|
||||
}
|
||||
|
||||
checkNoStoreLinks := Def.uncached {
|
||||
import java.nio.file.Files
|
||||
val out = (baseDirectory.value / "target" / "out").toPath
|
||||
val diskcache = (baseDirectory.value / "diskcache").toPath.toAbsolutePath.normalize
|
||||
if (Files.exists(out)) {
|
||||
val stream = Files.walk(out)
|
||||
try {
|
||||
val it = stream.iterator()
|
||||
while (it.hasNext) {
|
||||
val p = it.next()
|
||||
if (Files.isSymbolicLink(p)) {
|
||||
val raw = Files.readSymbolicLink(p)
|
||||
val resolved =
|
||||
(if (raw.isAbsolute) raw else Option(p.getParent).fold(raw)(_.resolve(raw)))
|
||||
.normalize()
|
||||
assert(!resolved.startsWith(diskcache), s"$p still points into $diskcache")
|
||||
}
|
||||
}
|
||||
} finally stream.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Populate the action cache, then verify clearCaches deletes the store entries
|
||||
# created by this test and the references under target/out, while preserving
|
||||
# everything else. Entries already present at baseline time (leftovers from a
|
||||
# previous test in the same batch sandbox) are out of scope for this test.
|
||||
> startServer
|
||||
> recordCacheBaseline
|
||||
> pure1
|
||||
$ exists target/out/a.txt
|
||||
> checkCacheNonEmpty
|
||||
$ mkdir diskcache/keep
|
||||
$ touch diskcache/keep/marker.txt
|
||||
$ touch target/out/regular.txt
|
||||
> clearCaches
|
||||
> checkCacheCleared
|
||||
> checkNoStoreLinks
|
||||
$ absent target/out/value
|
||||
$ absent target/out/a.txt
|
||||
$ exists target/out/regular.txt
|
||||
$ exists diskcache/keep/marker.txt
|
||||
> pure1
|
||||
$ exists target/out/a.txt
|
||||
> checkCacheNonEmpty
|
||||
Loading…
Reference in New Issue