[2.x] Make `clearCaches` clear the sbt 2 action cache as well

`clearCaches` only reset the in-memory session caches (compiler cache,
classloader cache, and file-cache stores) carried over from sbt 1, so none of
the action caches introduced in sbt 2 were actually cleared despite the help
text saying otherwise. The only way to remove the action cache was `cleanFull`,
which also wipes `target/out` entirely, including build outputs and the command
history.

`clearCaches` now additionally deletes, while respecting `cleanKeepFiles` and
`cleanKeepGlobs`:

- the contents of every local `DiskActionCacheStore` (the `cas/` and `ac/`
  directories under `localCacheDirectory`)
- the materialized task-value files under `target/out/value`
- symbolic links under `target/out` that point into a cleared store, which
  would otherwise be left dangling

Build outputs, the command history, and the boot directory are preserved.

Ordering and robustness details:

- The in-memory caches are reset before the on-disk stores are deleted, so
  files those caches may still hold open (e.g. jars referenced by cached
  classloaders) are released before their deletion is attempted. On Windows an
  open handle otherwise makes the underlying file undeletable.
- `target/out` references (symlinks) are deleted before the cas blobs they
  point at.
- Deletions retry on transient `IOException` (as `DiskActionCacheStore#syncFile`
  already does for writes) and, instead of being silently swallowed, any file
  that still cannot be deleted is reported: per-path at warn level, with the
  summary downgraded from "cleared" to "partially cleared".

The `clearCaches` help text is updated to describe the new behavior, and a
scripted test (`cache/clear-caches`) covers it. The test records a baseline of
existing store entries and asserts only that the entries it creates are cleared,
so unrelated blobs left behind by other tests sharing the batch sandbox are out
of scope.
This commit is contained in:
Kevin Lee 2026-07-19 02:05:55 +10:00
parent b3b1a3477e
commit 0085a98614
No known key found for this signature in database
GPG Key ID: 07666EBC3B80FE72
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