From d4f54fd5b62e642e4829158d801f9470d9249530 Mon Sep 17 00:00:00 2001 From: BrianHotopp Date: Sat, 30 May 2026 19:29:41 -0400 Subject: [PATCH] [2.x] fix: Report a missing input file clearly instead of an opaque SerializationException (#9271) When a file referenced by a task's inputs/outputs (e.g. Compile / resources += file("nope.txt")) does not exist, hashing the task's cache key threw a NoSuchFileException deep inside sjsonnew serialization. It surfaced as an opaque sjsonnew.SerializationException that dumped the entire input list, with the real cause buried several `Caused by:` levels down, so users routinely mistook it for a corrupt cache and reached for `clean`. ActionCache.mkInput now catches the hashing failure, detects a NoSuchFileException anywhere in the cause chain (ActionCache.findMissingFile), and throws a MessageOnlyException naming the file: [error] file referenced by the build does not exist: nope.txt util-cache gains a dependency on util-control (a leaf module, no cycle) for MessageOnlyException. Fixes #9217. Co-authored-by: Claude Opus 4.8 (1M context) --- notes/2.0.0/missing-file-error.md | 17 +++++++++++ .../src/main/scala/sbt/util/ActionCache.scala | 29 +++++++++++++++++-- .../test/scala/sbt/util/ActionCacheTest.scala | 16 +++++++++- 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 notes/2.0.0/missing-file-error.md diff --git a/notes/2.0.0/missing-file-error.md b/notes/2.0.0/missing-file-error.md new file mode 100644 index 000000000..27baec1dc --- /dev/null +++ b/notes/2.0.0/missing-file-error.md @@ -0,0 +1,17 @@ +### Clearer error when a referenced file does not exist + +If a file added to a task's inputs/outputs (e.g. `Compile / resources += file("nope.txt")`) +does not exist, tasks such as `package` previously failed while hashing the task's cache +key with an opaque `sjsonnew.SerializationException` that dumped the entire input list, with +the real cause (`NoSuchFileException`) buried several `Caused by:` levels down. This was +routinely mistaken for a corrupt cache. + +sbt now reports the missing file directly: + +``` +[error] file referenced by the build does not exist: nope.txt +``` + +This addresses [#9217][i9217]. + +[i9217]: https://github.com/sbt/sbt/issues/9217 diff --git a/util-cache/src/main/scala/sbt/util/ActionCache.scala b/util-cache/src/main/scala/sbt/util/ActionCache.scala index ed36ac63c..376ed87d9 100644 --- a/util-cache/src/main/scala/sbt/util/ActionCache.scala +++ b/util-cache/src/main/scala/sbt/util/ActionCache.scala @@ -10,10 +10,18 @@ package sbt.util import java.io.{ File, IOException, PrintWriter } import java.nio.charset.StandardCharsets -import java.nio.file.{ AtomicMoveNotSupportedException, Files, Path, Paths, StandardCopyOption } +import java.nio.file.{ + AtomicMoveNotSupportedException, + Files, + NoSuchFileException, + Path, + Paths, + StandardCopyOption +} import sbt.internal.util.{ ActionCacheEvent, CacheEventLog, + MessageOnlyException, SpawnExec, SpawnInput, StringVirtualFile1 @@ -295,10 +303,20 @@ object ActionCache: extraHash: Digest, cacheVersion: Long, ): Digest = + // Hashing serializes every task input; surface a missing input file directly rather than as an + // opaque serialization failure that buries it. + val inputHash = + try Hasher.hashUnsafe[I](key) + catch + case NonFatal(t) => + findMissingFile(t) match + case Some(path) => + throw MessageOnlyException(s"file referenced by the build does not exist: $path") + case None => throw t Digest.sha256Hash( (Vector( codeContentHash, - Digest.dummy(Hasher.hashUnsafe[I](key)), + Digest.dummy(inputHash), extraHash ) ++ { if cacheVersion == 0 then Vector.empty @@ -306,6 +324,13 @@ object ActionCache: })* ) + /** Walks `t`'s cause chain for a `NoSuchFileException`, returning the missing file's path. */ + private[sbt] def findMissingFile(t: Throwable): Option[String] = + t match + case null => None + case e: NoSuchFileException => Option(e.getFile) + case _ => findMissingFile(t.getCause) + private inline def mkValuePath(inputDigest: Digest): String = s"$${OUT}/value/${inputDigest}.json" diff --git a/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala b/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala index 06bb870b2..5978aeee5 100644 --- a/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala +++ b/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala @@ -2,7 +2,7 @@ package sbt.util import java.io.{ IOException, InputStream } import java.nio.charset.StandardCharsets -import java.nio.file.{ Files, Path, Paths } +import java.nio.file.{ Files, NoSuchFileException, Path, Paths } import java.util.Optional import java.util.concurrent.{ CyclicBarrier, ExecutorService, Executors, TimeUnit } @@ -27,6 +27,20 @@ import ActionCache.InternalActionResult object ActionCacheTest extends BasicTestSuite: val tags = CacheLevelTag.all.toList + test("findMissingFile extracts the path from a wrapped NoSuchFileException"): + val chain = new RuntimeException( + "error while writing LList", + new RuntimeException( + "error while writing the field sources", + new NoSuchFileException("not-exists.txt") + ) + ) + assert(ActionCache.findMissingFile(chain) == Some("not-exists.txt")) + + test("findMissingFile returns None when no file is missing"): + val chain = new RuntimeException("boom", new IllegalStateException("unrelated")) + assert(ActionCache.findMissingFile(chain) == None) + test("Disk cache can hold a blob"): withDiskCache(testHoldBlob)