From b7a0d357323000aaf68922f7a600a771094b7c97 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 00:14:45 +0000 Subject: [PATCH] Test that reconciliation goes on past entries it cannot read Each storage directory gets a directory the pass cannot list and a file whose info it cannot read. Their paths are longer than Linux accepts, so even root fails to read them. The walk reaches them before the cache's files. The test wants a warning naming each path and the error. It also wants the rest of the pass done: a variant file with no row adopted, the row of a missing variant file dropped, a source file with no row removed, and a wrong usage total corrected. On next the pass stops at the first such directory and returns its error. The fix follows in the next commit. Model: opus-5-5 --- internal/imgcache/eviction_internal_test.go | 132 ++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/internal/imgcache/eviction_internal_test.go b/internal/imgcache/eviction_internal_test.go index ed7b4eb..a6cdc4d 100644 --- a/internal/imgcache/eviction_internal_test.go +++ b/internal/imgcache/eviction_internal_test.go @@ -9,8 +9,10 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strings" "sync" + "syscall" "testing" "time" @@ -785,6 +787,136 @@ func TestReconcileVariantRowsDropsOnlyRowsOfMissingFiles(t *testing.T) { } } +// makeEntriesTooLongToRead makes a chain of directories inside dir, each +// named with 255 zeros, the longest name Linux allows. Linux refuses a path +// of 4096 bytes or more, and the chain goes as deep as it can below that. +// In its deepest directory it makes a directory and a file whose paths +// reach that length. Even root can then neither list that directory nor +// read the file's info. It returns their two paths. It makes them through +// an os.Root, which names one directory at a time, as no call may name +// the whole path. +func makeEntriesTooLongToRead(t *testing.T, dir string) []string { + t.Helper() + + name := strings.Repeat("0", 255) + deepest := dir + + for len(filepath.Join(deepest, name)) < 4096 { + deepest = filepath.Join(deepest, name) + } + + chain, err := filepath.Rel(dir, deepest) + if err != nil { + t.Fatalf("filepath.Rel() error = %v", err) + } + + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatalf("os.OpenRoot(%s) error = %v", dir, err) + } + + defer func() { _ = root.Close() }() + + err = root.MkdirAll(filepath.Join(chain, name), StorageDirPerm) + if err != nil { + t.Fatalf("failed to make a directory too deep to list: %v", err) + } + + fileName := strings.Repeat("1", 255) + + err = root.WriteFile(filepath.Join(chain, fileName), []byte("unreadable"), + StorageFilePerm) + if err != nil { + t.Fatalf("failed to make a file too deep to read: %v", err) + } + + return []string{filepath.Join(deepest, name), filepath.Join(deepest, fileName)} +} + +// TestReconciliationGoesOnPastEntriesItCannotRead puts, in each storage +// directory, a directory the pass cannot list and a file whose info it +// cannot read. The pass walks each directory in name order, so it reaches +// them before the cache's files, whose keys start with a letter. It must +// log a warning naming each one's path and the error, and still do the +// rest of its work: +// - adopt a variant file that has no row +// - drop the row of a variant whose file is missing +// - remove a source file that has no row +// - correct a wrong total cache usage +func TestReconciliationGoesOnPastEntriesItCannotRead(t *testing.T) { + t.Parallel() + + cache, _ := newEvictionTestCache(t, 1<<30) + ctx := t.Context() + + untrackedVariant := []byte("variant with no row") + + err := cache.variants.Write(string(testVariantKeyOne), untrackedVariant) + if err != nil { + t.Fatalf("failed to write a variant file with no row: %v", err) + } + + storeEvictionTestVariant(t, cache, testVariantKeyTwo, []byte("variant two")) + + err = os.Remove(cache.variants.keyToPath(string(testVariantKeyTwo))) + if err != nil { + t.Fatalf("failed to remove the second variant's file: %v", err) + } + + untrackedSourceKey := "ffeeddcc0001" + + err = cache.srcContent.Write(untrackedSourceKey, []byte("source with no row")) + if err != nil { + t.Fatalf("failed to write a source file with no row: %v", err) + } + + _, err = cache.db.ExecContext(ctx, + `UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`) + if err != nil { + t.Fatalf("failed to set a wrong total: %v", err) + } + + unreadable := slices.Concat( + makeEntriesTooLongToRead(t, cache.variants.baseDir), + makeEntriesTooLongToRead(t, cache.srcContent.baseDir), + ) + + logs := reconcileAsAtStartup(t, cache) + + for _, path := range unreadable { + assertWarningNames(t, logs, path) + + if !strings.Contains(logs, path+": "+syscall.ENAMETOOLONG.Error()) { + t.Errorf("no log line gives the error reading %s", path) + } + } + + rowQuery := `SELECT COUNT(*) FROM variant_content WHERE cache_key = ?` + + if n := countRows(t, cache, rowQuery, string(testVariantKeyOne)); n != 1 { + t.Errorf("the variant file with no row got %d rows, want 1", n) + } + + if n := countRows(t, cache, rowQuery, string(testVariantKeyTwo)); n != 0 { + t.Errorf("the variant with no file kept %d rows, want 0", n) + } + + _, err = os.Stat(cache.srcContent.keyToPath(untrackedSourceKey)) + if err == nil { + t.Error("the source file with no row is still on disk") + } + + usage, err := cache.UsageBytes(ctx) + if err != nil { + t.Fatalf("UsageBytes() error = %v", err) + } + + if usage != int64(len(untrackedVariant)) { + t.Errorf("UsageBytes() = %d, want %d, the size of the adopted variant", + usage, len(untrackedVariant)) + } +} + // TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves // reconciliation is not a one-shot startup-only pass: it must also run // on the periodic ticker, so a variant file that lands on disk with no