[2.0.x] fix: Ensure resources are copied atomically (#9234)

Bumps io to released 1.12.0 which contains the atomic-write changes
to IO.copyFile, IO.transfer(InputStream, File), and IO.jar/zip.
ActionCacheStore.putBlob now uses IO.transfer instead of duplicating
staging logic.

Co-authored-by: Anatolii Kmetiuk <anatoliykmetyuk@gmail.com>
This commit is contained in:
eugene yokota 2026-05-17 00:54:58 -04:00 committed by GitHub
parent 2369a5e1af
commit 8c5c1e6b6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 58 additions and 14 deletions

View File

@ -226,9 +226,7 @@ object Pkg:
val path = jar.getAbsolutePath
log.debug("Packaging " + path + " ...")
if (jar.exists)
if (jar.isFile)
IO.delete(jar)
else
if (!jar.isFile)
sys.error(path + " exists, but is not a regular file")
log.debug(sourcesDebugString(sources))
IO.jar(sources, jar, manifest, time)

View File

@ -1,6 +1,6 @@
package sbt.util
import java.io.{ IOException, RandomAccessFile }
import java.io.{ ByteArrayInputStream, IOException }
import java.nio.ByteBuffer
import java.nio.file.{
Files,
@ -240,23 +240,30 @@ class DiskActionCacheStore(base: Path, converter: FileConverter) extends Abstrac
def putBlob(input: InputStream, digest: Digest): Path =
val casFile = toCasFile(digest)
IO.transfer(input, casFile.toFile())
casFile
if isCompleteBlob(casFile, digest) then casFile
else
IO.transfer(input, casFile.toFile())
casFile
def putBlob(input: ByteBuffer, digest: Digest): Path =
val casFile = toCasFile(digest)
input.flip()
val file = RandomAccessFile(casFile.toFile(), "rw")
try
file.getChannel().write(input)
if isCompleteBlob(casFile, digest) then casFile
else
input.flip()
val bytes = new Array[Byte](input.remaining())
input.get(bytes)
IO.transfer(new ByteArrayInputStream(bytes), casFile.toFile())
casFile
finally file.close()
private def isCompleteBlob(casFile: Path, digest: Digest): Boolean =
try Files.exists(casFile) && Digest.sameDigest(casFile, digest)
catch case _: NoSuchFileException => false
private def getBlobs(refs: Seq[HashedVirtualFileRef]): Seq[VirtualFile] =
refs.flatMap: r =>
try
val casFile = toCasFile(Digest(r))
if casFile.toFile().exists then
if isCompleteBlob(casFile, Digest(r)) then
r match
case p: PathBasedFile => Some(p)
case _ =>
@ -270,7 +277,7 @@ class DiskActionCacheStore(base: Path, converter: FileConverter) extends Abstrac
refs.flatMap: r =>
try
val casFile = toCasFile(Digest(r))
if casFile.toFile().exists then
if isCompleteBlob(casFile, Digest(r)) then
// println(s"syncBlobs: $casFile exists for $r")
Some(syncFile(r, casFile, outputDirectory))
else None
@ -384,7 +391,7 @@ class DiskActionCacheStore(base: Path, converter: FileConverter) extends Abstrac
refs.flatMap: r =>
try
val casFile = toCasFile(Digest(r))
if casFile.toFile().exists then Some(r)
if isCompleteBlob(casFile, Digest(r)) then Some(r)
else None
// Digest(r) can throw NoSuchFileException
catch case _: NoSuchFileException => None

View File

@ -1,5 +1,8 @@
package sbt.util
import java.io.{ IOException, InputStream }
import java.nio.charset.StandardCharsets
import sbt.internal.util.CacheEventLog
import sbt.internal.util.StringVirtualFile1
import sbt.io.IO
@ -27,6 +30,32 @@ object ActionCacheTest extends BasicTestSuite:
test("Disk cache can hold a blob"):
withDiskCache(testHoldBlob)
test("Disk cache rejects truncated blobs"):
withDiskCache: cache =>
val blob = StringVirtualFile1("a.txt", "hello")
val digest = Digest(blob)
val ref: HashedVirtualFileRef = blob
val casFile = cache.toCasFile(digest)
Files.writeString(casFile, "he", StandardCharsets.UTF_8)
assert(cache.findBlobs(Seq(ref)).isEmpty)
cache.putBlobs(Seq(blob))
assert(cache.findBlobs(Seq(ref)) == Seq(ref))
assert(Files.readString(casFile, StandardCharsets.UTF_8) == "hello")
test("Disk cache removes staged blobs when writes fail"):
withDiskCache: cache =>
val blob = StringVirtualFile1("a.txt", "hello")
val digest = Digest(blob)
val casFile = cache.toCasFile(digest)
try
cache.putBlob(FailingInputStream("hello".getBytes(StandardCharsets.UTF_8), 2), digest)
assert(false, "expected blob write to fail")
catch case _: IOException => ()
assert(!Files.exists(casFile))
assert(Files.list(cache.casBase).toArray.isEmpty)
def testHoldBlob(cache: ActionCacheStore): Unit =
IO.withTemporaryDirectory: tempDir =>
val in = StringVirtualFile1(s"$tempDir/a.txt", "foo")
@ -35,6 +64,16 @@ object ActionCacheTest extends BasicTestSuite:
val actual = cache.syncBlobs(hashRefs, tempDir.toPath()).head
assert(actual.getFileName().toString() == "a.txt")
final class FailingInputStream(bytes: Array[Byte], failAt: Int) extends InputStream:
private var index = 0
override def read(): Int =
if index == failAt then throw IOException("simulated interrupted write")
else if index >= bytes.length then -1
else
val b = bytes(index) & 0xff
index += 1
b
test("In-memory cache can hold action value"):
withInMemoryCache(testActionCacheBasic)