Skip to content

Commit 08b2158

Browse files
authored
Handle truncated bundles gracefully during restore (#12)
When a bundle was uploaded partially (e.g. the uploader crashed mid-tar), restoring it would fail with an `unexpected EOF` error. This change catches that case and logs a warning instead, allowing the partially extracted cache to still be used.
1 parent 851fadd commit 08b2158

3 files changed

Lines changed: 79 additions & 1 deletion

File tree

gradlecache/extract_default.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@ func processEntry(
251251
if err != nil {
252252
largeChunkPool.Put(buf)
253253
close(chunks)
254+
// Remove the partially-written file so a truncated
255+
// bundle doesn't leave corrupt artifacts on disk.
256+
os.Remove(target) //nolint:errcheck
254257
return errors.Errorf("read %s: %w", hdr.Name, err)
255258
}
256259
*buf = (*buf)[:n]

gradlecache/gradlecache_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
archive_tar "archive/tar"
66
"bytes"
77
"context"
8+
stderrors "errors"
89
"fmt"
910
"io"
1011
"net/http"
@@ -487,6 +488,72 @@ func TestExtractZstdDrainsBufferedReaderToEOF(t *testing.T) {
487488
})
488489
}
489490

491+
// ─── Truncated archive test ──────────────────────────────────────────────────
492+
493+
// TestExtractBundleTruncatedArchive verifies that extractBundleZstd returns an
494+
// io.ErrUnexpectedEOF when the archive is truncated, and that no partially
495+
// written files are left on disk.
496+
func TestExtractBundleTruncatedArchive(t *testing.T) {
497+
ctx := context.Background()
498+
499+
t.Run("small files", func(t *testing.T) {
500+
srcDir := t.TempDir()
501+
must(t, os.MkdirAll(filepath.Join(srcDir, "caches"), 0o755))
502+
must(t, os.WriteFile(filepath.Join(srcDir, "caches", "first.jar"), bytes.Repeat([]byte("A"), 4096), 0o644))
503+
must(t, os.WriteFile(filepath.Join(srcDir, "caches", "second.jar"), bytes.Repeat([]byte("B"), 4096), 0o644))
504+
505+
var archive bytes.Buffer
506+
must(t, CreateDeltaTarZstd(ctx, &archive, srcDir, []string{"caches/first.jar", "caches/second.jar"}))
507+
508+
truncated := archive.Bytes()[:archive.Len()*60/100]
509+
gradleHome := t.TempDir()
510+
projectDir := t.TempDir()
511+
512+
_, err := extractBundleZstd(ctx, bytes.NewReader(truncated), []extractRule{
513+
{prefix: "caches/", baseDir: gradleHome},
514+
}, projectDir, false)
515+
516+
if err == nil {
517+
t.Fatal("expected an error from truncated archive, got nil")
518+
}
519+
if !stderrors.Is(err, io.ErrUnexpectedEOF) {
520+
t.Fatalf("expected io.ErrUnexpectedEOF in error chain, got: %v", err)
521+
}
522+
})
523+
524+
t.Run("large file no partial artifact", func(t *testing.T) {
525+
srcDir := t.TempDir()
526+
must(t, os.MkdirAll(filepath.Join(srcDir, "caches"), 0o755))
527+
// 5 MB file — larger than maxBufferedFileSize so it takes the
528+
// streaming large-file path.
529+
must(t, os.WriteFile(filepath.Join(srcDir, "caches", "big.jar"), bytes.Repeat([]byte("X"), 5<<20), 0o644))
530+
531+
var archive bytes.Buffer
532+
must(t, CreateDeltaTarZstd(ctx, &archive, srcDir, []string{"caches/big.jar"}))
533+
534+
// Truncate at ~60% so the large-file read fails mid-stream.
535+
truncated := archive.Bytes()[:archive.Len()*60/100]
536+
gradleHome := t.TempDir()
537+
projectDir := t.TempDir()
538+
539+
_, err := extractBundleZstd(ctx, bytes.NewReader(truncated), []extractRule{
540+
{prefix: "caches/", baseDir: gradleHome},
541+
}, projectDir, false)
542+
543+
if err == nil {
544+
t.Fatal("expected an error from truncated archive, got nil")
545+
}
546+
if !stderrors.Is(err, io.ErrUnexpectedEOF) {
547+
t.Fatalf("expected io.ErrUnexpectedEOF in error chain, got: %v", err)
548+
}
549+
550+
// The partially-written large file must not remain on disk.
551+
if _, statErr := os.Stat(filepath.Join(gradleHome, "caches", "big.jar")); statErr == nil {
552+
t.Fatal("partially-written big.jar should have been removed")
553+
}
554+
})
555+
}
556+
490557
// ─── Round-trip archive test ─────────────────────────────────────────────────
491558

492559
// TestTarZstdRoundTrip verifies that CreateTarZstd → extractTarZstd preserves

gradlecache/restore.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,15 @@ func Restore(ctx context.Context, cfg RestoreConfig) error {
436436
netTiming := &timingReader{r: cb}
437437
ps, err := extractBundleZstd(ctx, netTiming, rules, cfg.ProjectDir, !gradleUserHomeEmpty)
438438
if err != nil {
439-
return errors.Wrap(err, "extract bundle")
439+
// A truncated tar archive (e.g. from a crash during upload) produces an
440+
// unexpected EOF during extraction. Treat this as a warning rather than
441+
// a fatal error: the files extracted before the truncation point are
442+
// still usable and better than no cache at all.
443+
if errors.Is(err, io.ErrUnexpectedEOF) {
444+
log.Warn("bundle appears truncated! using partially extracted cache", "err", err)
445+
} else {
446+
return errors.Wrap(err, "extract bundle")
447+
}
440448
}
441449

442450
totalElapsed := time.Since(dlStart)

0 commit comments

Comments
 (0)