Skip to content

Commit 7980be5

Browse files
authored
fix(snapshot): fall back to non-sparse tar when segment map exceeds PAX cap (#23)
Go's archive/tar caps the encoded PAX block at maxSpecialFileSize = 1<<20 (see archive/tar/format.go). Our sparse encoding stuffs the entire sparse-segment list into a single COCOON.sparse.map PAX record, which is fine for typical disk images but trips the cap when the underlying file is highly fragmented. Reproduced 2026-05-05 against a live cocoon Windows VM running the simular-pro-agent-runtime 1.8.0 build (Electron app, real Firebase WebSocket signed-in): the agent's V8 heap + IPC + WebSocket pools fragment the guest memory enough that memory-ranges yields tens of thousands of sparse segments; the resulting JSON exceeds 1MB and `cocoon snapshot export` fails with: Error: write archive: write header memory-ranges: archive/tar: header field too long vk-cocoon then loops indefinitely retrying the push (see project_cocoon_sparse_pax_overflow memory note in vm-service tree), silently breaking hibernate. The fix detects the overflow up front (mapJSON > ~800KB, well below the 1MB cap) and falls back to writing the file as a regular, non-sparse tar entry. This loses the sparse-export size advantage on the affected file (memory-ranges can be GB-scale), but those files are not the dominant size driver in a snapshot — and a successful larger push beats a hung loop. Empirical Go-tar limit measured: 30k segments (~736KB JSON) succeed, 50k (~1.2MB) fail. Reader path is unchanged. The fallback emits a standard tar entry that existing extract logic already handles via the non-sparse code path. Tests: - TestTarFileMaybeSparse_FallsBackOnLargeMap — verifies the fallback triggers when the map exceeds the cap and that no PAX sparse records are written. - TestTarFileMaybeSparse_FallbackRoundTrip — extract of a fallback-encoded file matches the original byte-for-byte. - TestTarFileMaybeSparse_PreservesSparsePathForSmallMaps — sanity that small fragmentation still uses the sparse encoding.
1 parent 7174e76 commit 7980be5

2 files changed

Lines changed: 161 additions & 8 deletions

File tree

utils/tar_sparse_linux.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import (
1111
"strconv"
1212
)
1313

14+
// Sparse-map JSON cap (margin under tar's 1MB PAX block); var so tests can override.
15+
var maxSparseMapJSONSize = 800 * 1024
16+
1417
// tarFileMaybeSparse writes a file to tw using our custom COCOON.sparse PAX
1518
// format when the file has holes (detected via SEEK_HOLE/SEEK_DATA).
1619
//
@@ -21,6 +24,7 @@ import (
2124
// - the file is empty or very small
2225
// - SEEK_HOLE/SEEK_DATA fails (unsupported filesystem)
2326
// - the file has no holes (not actually sparse)
27+
// - the segment-map JSON would exceed tar's 1MB PAX cap
2428
func tarFileMaybeSparse(tw *tar.Writer, path, nameInTar string) error {
2529
f, err := os.Open(path) //nolint:gosec
2630
if err != nil {
@@ -41,28 +45,27 @@ func tarFileMaybeSparse(tw *tar.Writer, path, nameInTar string) error {
4145
segments, err := scanDataSegments(int(f.Fd()), size)
4246
if err != nil {
4347
// SEEK_HOLE/SEEK_DATA unsupported (e.g. tmpfs, NFS). Fall back.
44-
if _, seekErr := f.Seek(0, io.SeekStart); seekErr != nil {
45-
return fmt.Errorf("seek %s: %w", path, seekErr)
46-
}
47-
return tarFileFrom(tw, f, fi, nameInTar)
48+
return rewindAndTarFull(tw, f, fi, path, nameInTar)
4849
}
4950

5051
var dataSize int64
5152
for _, seg := range segments {
5253
dataSize += seg.Length
5354
}
5455
if dataSize == size {
55-
if _, seekErr := f.Seek(0, io.SeekStart); seekErr != nil {
56-
return fmt.Errorf("seek %s: %w", path, seekErr)
57-
}
58-
return tarFileFrom(tw, f, fi, nameInTar)
56+
return rewindAndTarFull(tw, f, fi, path, nameInTar)
5957
}
6058

6159
mapJSON, err := json.Marshal(segments)
6260
if err != nil {
6361
return fmt.Errorf("marshal sparse map for %s: %w", path, err)
6462
}
6563

64+
// Segment-map JSON exceeds tar's 1MB PAX cap. Fall back to non-sparse.
65+
if len(mapJSON) > maxSparseMapJSONSize {
66+
return rewindAndTarFull(tw, f, fi, path, nameInTar)
67+
}
68+
6669
hdr, err := tar.FileInfoHeader(fi, "")
6770
if err != nil {
6871
return fmt.Errorf("tar header for %s: %w", path, err)
@@ -89,3 +92,11 @@ func tarFileMaybeSparse(tw *tar.Writer, path, nameInTar string) error {
8992

9093
return nil
9194
}
95+
96+
// rewindAndTarFull seeks f to the start and writes it as a non-sparse tar entry.
97+
func rewindAndTarFull(tw *tar.Writer, f *os.File, fi os.FileInfo, path, nameInTar string) error {
98+
if _, err := f.Seek(0, io.SeekStart); err != nil {
99+
return fmt.Errorf("seek %s: %w", path, err)
100+
}
101+
return tarFileFrom(tw, f, fi, nameInTar)
102+
}

utils/tar_sparse_linux_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//go:build linux
2+
3+
package utils
4+
5+
import (
6+
"archive/tar"
7+
"bytes"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
)
12+
13+
// writeSparseFile alternates numSegments data regions of blockSize with equal-sized holes.
14+
func writeSparseFile(t *testing.T, path string, numSegments, blockSize int) int64 {
15+
t.Helper()
16+
f, err := os.Create(path)
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
defer f.Close() //nolint:errcheck
21+
for i := range numSegments {
22+
offset := int64(i) * int64(blockSize) * 2
23+
data := bytes.Repeat([]byte{byte((i % 250) + 1)}, blockSize)
24+
if _, err := f.WriteAt(data, offset); err != nil {
25+
t.Fatal(err)
26+
}
27+
}
28+
fi, err := f.Stat()
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
return fi.Size()
33+
}
34+
35+
// Segment-map JSON over the cap → tarFileMaybeSparse falls back to non-sparse.
36+
func TestTarFileMaybeSparse_FallsBackOnLargeMap(t *testing.T) {
37+
orig := maxSparseMapJSONSize
38+
maxSparseMapJSONSize = 4 * 1024
39+
t.Cleanup(func() { maxSparseMapJSONSize = orig })
40+
41+
dir := t.TempDir()
42+
path := filepath.Join(dir, "memory-ranges")
43+
logicalSize := writeSparseFile(t, path, 200, 4096)
44+
45+
var buf bytes.Buffer
46+
tw := tar.NewWriter(&buf)
47+
if err := tarFileMaybeSparse(tw, path, "memory-ranges"); err != nil {
48+
t.Fatalf("tarFileMaybeSparse: %v (expected fallback to succeed)", err)
49+
}
50+
if err := tw.Close(); err != nil {
51+
t.Fatal(err)
52+
}
53+
54+
tr := tar.NewReader(&buf)
55+
hdr, err := tr.Next()
56+
if err != nil {
57+
t.Fatal(err)
58+
}
59+
if hdr.Name != "memory-ranges" {
60+
t.Fatalf("name = %q, want memory-ranges", hdr.Name)
61+
}
62+
if _, ok := hdr.PAXRecords[paxSparseMap]; ok {
63+
t.Errorf("PAX %s present, expected non-sparse fallback", paxSparseMap)
64+
}
65+
if hdr.Size != logicalSize {
66+
t.Errorf("hdr.Size = %d, want logical size %d", hdr.Size, logicalSize)
67+
}
68+
}
69+
70+
// Fallback round-trip: extracted file matches the original byte-for-byte (holes serialised as zeros).
71+
func TestTarFileMaybeSparse_FallbackRoundTrip(t *testing.T) {
72+
orig := maxSparseMapJSONSize
73+
maxSparseMapJSONSize = 4 * 1024
74+
t.Cleanup(func() { maxSparseMapJSONSize = orig })
75+
76+
dir := t.TempDir()
77+
path := filepath.Join(dir, "memory-ranges")
78+
writeSparseFile(t, path, 200, 4096)
79+
80+
var buf bytes.Buffer
81+
tw := tar.NewWriter(&buf)
82+
if err := tarFileMaybeSparse(tw, path, "memory-ranges"); err != nil {
83+
t.Fatal(err)
84+
}
85+
if err := tw.Close(); err != nil {
86+
t.Fatal(err)
87+
}
88+
89+
dst := t.TempDir()
90+
if err := ExtractTar(dst, &buf); err != nil {
91+
t.Fatalf("ExtractTar: %v", err)
92+
}
93+
94+
got, err := os.ReadFile(filepath.Join(dst, "memory-ranges"))
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
want, err := os.ReadFile(path)
99+
if err != nil {
100+
t.Fatal(err)
101+
}
102+
if !bytes.Equal(got, want) {
103+
t.Errorf("extracted content differs (len got=%d want=%d)", len(got), len(want))
104+
}
105+
}
106+
107+
// Small fragmentation still takes the sparse path; fallback only on PAX overflow.
108+
func TestTarFileMaybeSparse_PreservesSparsePathForSmallMaps(t *testing.T) {
109+
dir := t.TempDir()
110+
path := filepath.Join(dir, "small-sparse")
111+
f, err := os.Create(path)
112+
if err != nil {
113+
t.Fatal(err)
114+
}
115+
if _, err := f.WriteAt(bytes.Repeat([]byte{0xAA}, 4096), 0); err != nil {
116+
t.Fatal(err)
117+
}
118+
if _, err := f.WriteAt(bytes.Repeat([]byte{0xBB}, 4096), 1<<20); err != nil {
119+
t.Fatal(err)
120+
}
121+
if err := f.Close(); err != nil {
122+
t.Fatal(err)
123+
}
124+
125+
var buf bytes.Buffer
126+
tw := tar.NewWriter(&buf)
127+
if err := tarFileMaybeSparse(tw, path, "small-sparse"); err != nil {
128+
t.Fatal(err)
129+
}
130+
if err := tw.Close(); err != nil {
131+
t.Fatal(err)
132+
}
133+
134+
tr := tar.NewReader(&buf)
135+
hdr, err := tr.Next()
136+
if err != nil {
137+
t.Fatal(err)
138+
}
139+
if _, ok := hdr.PAXRecords[paxSparseMap]; !ok {
140+
t.Errorf("expected sparse PAX record for small fragmentation, got none")
141+
}
142+
}

0 commit comments

Comments
 (0)