package main // Gate: a push whose file stops answering leaves behind at most one stuck // read per folder, and at most backupReadsMax on the whole bastion, // however often a host starts a push there. A start in a folder whose read // has not returned is refused with a sentence; when the read returns the // folder is read again. A push that waits for a slot fails on that wait with // a sentence naming what it waited on, never one blaming its own folder. import ( "encoding/json" "errors" "os" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "testing" "time" ) // stuckReads makes every read of a pushed file block until release, and // counts the reads blocked at any moment: each one is a goroutine (and, on a // real mount, an OS thread) that cannot be stopped. type stuckReads struct { gate chan struct{} once sync.Once blocked atomic.Int64 prev func(*os.File, []byte) (int, error) } // newStuckReads blocks the reads of files in dirs, or of every file when no // folder is named; the others read as usual. func newStuckReads(t *testing.T, dirs ...string) *stuckReads { t.Helper() s := &stuckReads{gate: make(chan struct{})} dead := map[string]bool{} for _, d := range dirs { dead[d] = true } backupPushHookMu.Lock() s.prev = backupPushRead prev := s.prev backupPushRead = func(f *os.File, p []byte) (int, error) { if len(dead) > 0 && !dead[filepath.Dir(f.Name())] { return prev(f, p) } s.blocked.Add(1) defer s.blocked.Add(-1) <-s.gate return 0, errors.New("the mount went away") } backupPushHookMu.Unlock() t.Cleanup(s.release) return s } // release lets the blocked reads return, and the file answers again. func (s *stuckReads) release() { s.once.Do(func() { backupPushHookMu.Lock() backupPushRead = s.prev backupPushHookMu.Unlock() close(s.gate) }) } func shortPushStall(t *testing.T) { t.Helper() backupPushHookMu.Lock() prev := backupPushFileStall backupPushFileStall = 200 * time.Millisecond backupPushHookMu.Unlock() t.Cleanup(func() { backupPushHookMu.Lock(); backupPushFileStall = prev; backupPushHookMu.Unlock() }) } // pushSlotsIdle waits for reads left blocked by an earlier test to return. func pushSlotsIdle(t *testing.T) { t.Helper() eventually(t, "the push slots of an earlier test are freed", func() bool { backupReadMu.Lock() defer backupReadMu.Unlock() return len(backupReads) == 0 }) } func eventually(t *testing.T, what string, ok func() bool) { t.Helper() deadline := time.Now().Add(5 * time.Second) for !ok() { if time.Now().After(deadline) { t.Fatalf("never: %s", what) } time.Sleep(10 * time.Millisecond) } } // fdsOn counts this process's open descriptors on path, or -1 where the // system does not list them. func fdsOn(path string) int { dir := "/proc/self/fd" if _, err := os.Stat(dir); err != nil { dir = "/dev/fd" } ents, err := os.ReadDir(dir) if err != nil { return -1 } want, err := filepath.EvalSymlinks(path) if err != nil { return -1 } n := 0 for _, e := range ents { if l, err := os.Readlink(filepath.Join(dir, e.Name())); err == nil && l == want { n++ } } return n } func startPush(t *testing.T, root, rel, sum string) (string, string, int) { t.Helper() return driveBackupOp(t, "backup_push", "", root, backupPushReq{Op: "start", Path: rel, URL: "https://storage.example/put?sig=1", SHA256: sum, Headers: signedPut}) } // pushStarted starts a push and returns its job id. func pushStarted(t *testing.T, root, rel, sum string) string { t.Helper() out, errOut, code := startPush(t, root, rel, sum) if code != 0 { t.Fatalf("start refused: %s", errOut) } var st map[string]string if err := json.Unmarshal([]byte(out), &st); err != nil || st["job_id"] == "" { t.Fatalf("start reply: %s", out) } return st["job_id"] } func mustFailStuck(t *testing.T, root, id string) { t.Helper() if st := awaitPush(t, root, id); st["state"] != "failed" || !strings.Contains(st["error"], "stopped answering") { t.Fatalf("a push whose file stopped answering reads %v", st) } } // failures waits for each push to fail and counts the ones whose own read // stopped answering and the ones that failed waiting, with a sentence that // holds want, for a slot to read. Any other outcome fails the test. func failures(t *testing.T, want string, pushes ...[2]string) (stuck, waited int) { t.Helper() for _, p := range pushes { st := awaitPush(t, p[0], p[1]) switch { case st["state"] == "failed" && strings.Contains(st["error"], "stopped answering"): stuck++ case st["state"] == "failed" && strings.Contains(st["error"], "this push waited more than") && strings.Contains(st["error"], want): waited++ default: t.Fatalf("a push from a dead folder reads %v", st) } } return stuck, waited } // capReads lowers backupReadsMax to n for the test. func capReads(t *testing.T, n int) { t.Helper() backupReadMu.Lock() prev := backupReadsMax backupReadsMax = n backupReadMu.Unlock() t.Cleanup(func() { backupReadMu.Lock(); backupReadsMax = prev; backupReadMu.Unlock() }) } func TestRepeatedPushesFromADeadFolderHoldOneRead(t *testing.T) { stubPublicDNS(t) shortPushStall(t) pushSlotsIdle(t) root := t.TempDir() rel, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000))) abs := filepath.Join(root, fixtureData) s := newStuckReads(t) // Two starts back to back, before either is known to be stuck: one read // blocks, the other push waits for the folder and fails at the same // bound without a read of its own, saying the folder has not answered. a := pushStarted(t, root, rel, sum) b := pushStarted(t, root, rel, sum) if stuck, waited := failures(t, "the folder of "+rel+" has not answered an earlier push", [2]string{root, a}, [2]string{root, b}); stuck != 1 || waited != 1 { t.Fatalf("%d pushes failed on their own read and %d waiting for the folder; want 1 and 1", stuck, waited) } eventually(t, "one read is left blocked and one descriptor open", func() bool { f := fdsOn(abs) return s.blocked.Load() == 1 && (f == -1 || f == 1) }) time.Sleep(100 * time.Millisecond) g0, f0 := runtime.NumGoroutine(), fdsOn(abs) for i := 0; i < 10; i++ { out, errOut, code := startPush(t, root, rel, sum) if code == 0 { t.Fatalf("start %d in a folder whose read has not returned was accepted: %s", i, out) } if !strings.Contains(errOut, "backup_push: the folder of "+rel+" has not answered an earlier push") || !strings.Contains(errOut, "nothing more is sent from that folder until it answers") { t.Fatalf("start %d refused without the sentence: %s", i, errOut) } } eventually(t, "the refused starts leave no goroutine behind", func() bool { return runtime.NumGoroutine() <= g0+3 }) if n := s.blocked.Load(); n != 1 { t.Fatalf("%d reads are blocked on the dead folder after repeated starts; want 1", n) } if f := fdsOn(abs); f != f0 { t.Fatalf("%d descriptors are open on the file after repeated starts; was %d", f, f0) } } func TestAPushFolderIsReadAgainWhenItsReadReturns(t *testing.T) { stubPublicDNS(t) shortPushStall(t) pushSlotsIdle(t) root := t.TempDir() rel, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000))) s := newStuckReads(t) mustFailStuck(t, root, pushStarted(t, root, rel, sum)) if _, errOut, code := startPush(t, root, rel, sum); code == 0 || !strings.Contains(errOut, "has not answered an earlier push") { t.Fatalf("a start while the read is stuck: %d %s", code, errOut) } s.release() eventually(t, "the returned read frees its folder", func() bool { backupReadMu.Lock() defer backupReadMu.Unlock() return len(backupReads) == 0 }) // Read to the end this time: a digest that is not the file's fails on // the comparison, which only a full read reaches. id := pushStarted(t, root, rel, strings.Repeat("ab", 32)) if st := awaitPush(t, root, id); st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") { t.Fatalf("the folder was not read again once its read returned: %v", st) } } func TestStuckPushReadsAreCappedPerBastion(t *testing.T) { stubPublicDNS(t) shortPushStall(t) pushSlotsIdle(t) capReads(t, 2) type folder struct{ root, rel, sum string } var fs []folder for i := 0; i < 4; i++ { root := t.TempDir() rel, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000))) fs = append(fs, folder{root, rel, sum}) } s := newStuckReads(t) // Three dead folders started at once: two reads block, the third push // waits for a slot and fails at the bound without a read of its own, // saying the cap is held. var ps [][2]string for _, f := range fs[:3] { ps = append(ps, [2]string{f.root, pushStarted(t, f.root, f.rel, f.sum)}) } if stuck, waited := failures(t, "2 reads of backup folders have not returned", ps...); stuck != 2 || waited != 1 { t.Fatalf("%d pushes failed on their own read and %d waiting for a slot; want 2 and 1", stuck, waited) } time.Sleep(100 * time.Millisecond) if n := s.blocked.Load(); n != 2 { t.Fatalf("%d reads are blocked; the cap is 2", n) } // A folder that answers is refused too while the cap is held. f := fs[3] _, errOut, code := startPush(t, f.root, f.rel, f.sum) if code == 0 || !strings.Contains(errOut, "backup_push: 2 reads of backup folders have not returned") || !strings.Contains(errOut, "no push starts on this machine until one does") { t.Fatalf("a start with the cap held: %d %s", code, errOut) } if n := s.blocked.Load(); n != 2 { t.Fatalf("%d reads are blocked after a refused start; want 2", n) } s.release() eventually(t, "the returned reads free their slots", func() bool { backupReadMu.Lock() defer backupReadMu.Unlock() return len(backupReads) == 0 }) id := pushStarted(t, f.root, f.rel, strings.Repeat("ab", 32)) if st := awaitPush(t, f.root, id); st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") { t.Fatalf("a push after the reads returned: %v", st) } } // A push already under way in a folder that answers, waiting for a slot while // other folders' reads hold the cap, fails with the sentence about those // folders, not one blaming its own: the wait is not a read of its file. func TestAPushWaitingForASlotIsNotBlamedOnItsOwnFolder(t *testing.T) { stubPublicDNS(t) pushSlotsIdle(t) // Long enough that the third start is taken before the two stuck reads // are past the bound, even on a loaded machine. backupPushHookMu.Lock() prevStall := backupPushFileStall backupPushFileStall = time.Second backupPushHookMu.Unlock() t.Cleanup(func() { backupPushHookMu.Lock(); backupPushFileStall = prevStall; backupPushHookMu.Unlock() }) capReads(t, 2) dead1, dead2, healthy := t.TempDir(), t.TempDir(), t.TempDir() body := []byte(strings.Repeat("a backup ", 1000)) rel, sum := pushFixture(t, dead1, body) pushFixture(t, dead2, body) pushFixture(t, healthy, body) s := newStuckReads(t, dead1, dead2) a := pushStarted(t, dead1, rel, sum) b := pushStarted(t, dead2, rel, sum) eventually(t, "both dead folders hold a read", func() bool { return s.blocked.Load() == 2 }) // Neither read is past the bound yet, so this start is taken; its first // operation waits for a slot. c := pushStarted(t, healthy, rel, sum) mustFailStuck(t, dead1, a) mustFailStuck(t, dead2, b) st := awaitPush(t, healthy, c) if st["state"] != "failed" || strings.Contains(st["error"], "stopped answering") || !strings.Contains(st["error"], "this push waited more than 1s for a turn to read its file: "+ "2 reads of backup folders have not returned") { t.Fatalf("a push from a folder that answers, waiting for a slot, reads %v", st) } if n := s.blocked.Load(); n != 2 { t.Fatalf("%d reads are blocked; the cap is 2", n) } }