This commit is contained in:
Kevin Lee 2026-07-19 17:53:37 +00:00 committed by GitHub
commit 6db0845044
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 202 additions and 1 deletions

View File

@ -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."

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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