//go:build linux package stopevidence import ( "context" "errors" "os" "os/exec" "path/filepath" "reflect" "strconv" "testing" "time" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/moves" ) // ยง14.1: Reconstruct against a real cgroup v2 hierarchy with a forked // descendant that closes its descriptors and outlives its parent (witness // wEvidenceWholeTree). Needs a delegated cgroup root: CARLOS_CGROUP_TEST=1 // and CARLOS_CGROUP_ROOT=, for instance from inside // `systemd-run --user --scope -p Delegate=yes`. func TestReconstructStopEvidenceKillsTheWholeTree(t *testing.T) { root := os.Getenv("CARLOS_CGROUP_ROOT") if os.Getenv("CARLOS_CGROUP_TEST") != "1" || root == "" { t.Skip("needs CARLOS_CGROUP_TEST=1 and a delegated cgroup v2 root") } m, err := cgroup.SetupAt(root, t.Logf) if err != nil { t.Skip(err) } host := "ev.go.example.com" // A parent that forks a child which detaches and sleeps, then exits. cmd, err := m.Start(host, cgroup.Limit{}, func() *exec.Cmd { return exec.Command("sh", "-c", "(sleep 300 /dev/null 2>&1 &) ; exit 0") }) if err != nil { t.Fatal(err) } cmd.Wait() c, err := m.OpenCleanup(host) if err != nil { t.Fatal(err) } defer c.Close() if pop, err := c.Populated(); err != nil || !pop { t.Fatalf("the descendant should still be alive before Reconstruct: populated %v err %v", pop, err) } // The real /proc entry of this process, projected into an otherwise // empty scan root (as TestFDHoldersRealProc does): the whole-machine // scan needs every uid's /proc, which the agent has under // ProtectProc=invisible and a test usually does not. procRoot := projectedProc(t) db := filepath.Join(t.TempDir(), "ev.go.example.com.db") rep, err := Reconstruct(context.Background(), ReconstructInputs{Host: host, Cgroups: m, DBPaths: []string{db}, ProcRoot: procRoot, LeaseFree: func(context.Context) (bool, error) { return true, nil }}) if err != nil { t.Fatal(err) } if !rep.Steps[moves.StepTree].OK || !rep.TreeStopped() || rep.Complete() { t.Fatalf("tree evidence after the parent exited: %+v (Complete needs the caller's replica step)", rep) } if pop, err := c.Populated(); err != nil || pop { t.Fatalf("the descendant survived: populated %v err %v", pop, err) } } // Step 2 against the running kernel: the leaf /proc/self/cgroup names is // found under the cgroup2 mount /proc/self/mountinfo names, and its // cgroup.procs holds this process. Whether StepStray passes depends on // what else shares the test's cgroup (alone in a fresh scope, it passes; // on a CI runner the leaf holds other processes, some listed as pid 0 // because they are outside this namespace, and it fails). The read itself // must succeed either way, and the step's verdict must agree with Stray. func TestReconstructReadsTheRealOwnLeaf(t *testing.T) { if _, ok := cgroup.ParseSelfCgroup(readOrSkip(t, "/proc/self/cgroup")); !ok { t.Skip("no cgroup v2 leaf for this process") } db := filepath.Join(t.TempDir(), "h.example.com.db") rep, err := reconstruct(context.Background(), ReconstructInputs{Host: "h.example.com", DBPaths: []string{db}, ProcRoot: projectedProc(t), LeaseFree: leaseFree}, opener(&fakeTree{}, nil)) if err != nil { t.Fatal(err) } if _, ok := rep.Steps[moves.StepStray]; !ok { t.Fatal("no stray step") } for _, p := range rep.Stray { if p == os.Getpid() { t.Fatal("the agent counted itself as a stray") } } // A pass means the agent is alone; a failure must be accounted for by // the other members the read found, 0 marking those it cannot name. if c := rep.Steps[moves.StepStray]; c.OK != (len(rep.Stray) == 0) { t.Fatalf("stray step %+v disagrees with the %d other members read: %v", c, len(rep.Stray), rep.Stray) } t.Logf("stray step %+v, %d others in the leaf", rep.Steps[moves.StepStray], len(rep.Stray)) } func readOrSkip(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Skip(err) } return string(b) } func TestReconstructStopEvidenceRefusesAStrayProcess(t *testing.T) { // A fake self-cgroup listing with a second pid refuses with StepStray. rep := strayCheck([]int{os.Getpid(), 99999}, os.Getpid()) if rep.OK { t.Fatal("a stray pid passed") } if rep.Reason != "pid 99999 in the agent's cgroup is not the agent" { t.Fatalf("reason %q", rep.Reason) } if got := strayCheck([]int{os.Getpid()}, os.Getpid()); !got.OK { t.Fatalf("only the agent: %+v", got) } } // projectedProc is a scan root holding this process's real /proc entry, // under its pid and as self. func projectedProc(t *testing.T) string { t.Helper() root := t.TempDir() pid := strconv.Itoa(os.Getpid()) for _, name := range []string{pid, "self"} { if err := os.Symlink(filepath.Join("/proc", pid), filepath.Join(root, name)); err != nil { t.Fatal(err) } } return root } // boxFixture is a fake box: a proc root whose self/cgroup names the // agent's leaf, a self/mountinfo mounting a cgroup2 hierarchy at a temp // directory (never /sys/fs/cgroup), and that leaf's cgroup.procs. type boxFixture struct { proc, mount, leafDir, db string } func newBoxFixture(t *testing.T, leafPIDs ...int) *boxFixture { t.Helper() dir := t.TempDir() f := &boxFixture{proc: filepath.Join(dir, "proc"), mount: filepath.Join(dir, "cg fs"), db: filepath.Join(dir, "data", "h.example.com.db")} f.leafDir = filepath.Join(f.mount, "system.slice", "carlos-edge.service", "supervise") for _, d := range []string{filepath.Join(f.proc, "self"), f.leafDir, filepath.Dir(f.db)} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } write(t, filepath.Join(f.proc, "self", "cgroup"), "0::/system.slice/carlos-edge.service/supervise\n") write(t, filepath.Join(f.proc, "self", "mountinfo"), "22 1 0:21 / /proc rw,nosuid,nodev,noexec,relatime shared:5 - proc proc rw\n"+ "32 24 0:27 / "+escapeMount(f.mount)+" rw,nosuid,nodev,noexec,relatime shared:8 - cgroup2 cgroup2 rw,nsdelegate\n") if leafPIDs == nil { leafPIDs = []int{os.Getpid()} } f.setLeaf(t, leafPIDs...) return f } func (f *boxFixture) setLeaf(t *testing.T, pids ...int) { t.Helper() var body string for _, p := range pids { body += strconv.Itoa(p) + "\n" } write(t, filepath.Join(f.leafDir, "cgroup.procs"), body) } // holdDB makes pid a process with the path open as descriptor 7. func (f *boxFixture) holdDB(t *testing.T, pid int, path string) { t.Helper() fd := filepath.Join(f.proc, strconv.Itoa(pid), "task", strconv.Itoa(pid), "fd") if err := os.MkdirAll(fd, 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(path, filepath.Join(fd, "7")); err != nil { t.Fatal(err) } } func escapeMount(p string) string { out := "" for _, r := range p { if r == ' ' { out += `\040` continue } out += string(r) } return out } func write(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } // fakeTree is the host cgroup as reconstruct sees it. type fakeTree struct { killErr error deadline bool killed bool closed bool } func (g *fakeTree) KillAndEmpty(ctx context.Context) error { g.killed = true _, g.deadline = ctx.Deadline() return g.killErr } func (g *fakeTree) Close() error { g.closed = true; return nil } func opener(g *fakeTree, err error) func(string) (treeGroup, error) { return func(string) (treeGroup, error) { if err != nil { return nil, err } return g, nil } } func leaseFree(context.Context) (bool, error) { return true, nil } func (f *boxFixture) inputs() ReconstructInputs { return ReconstructInputs{Host: "h.example.com", DBPaths: []string{f.db}, ProcRoot: f.proc, LeaseFree: leaseFree} } func TestReconstructAllStepsPass(t *testing.T) { f := newBoxFixture(t) g := &fakeTree{} rep, err := reconstruct(context.Background(), f.inputs(), opener(g, nil)) if err != nil { t.Fatal(err) } for _, step := range []string{moves.StepTree, moves.StepStray, moves.StepFDs, moves.StepLease} { if c := rep.Steps[step]; !c.OK || c.Reason != "" { t.Fatalf("%s: %+v", step, c) } } if !rep.TreeStopped() || rep.Complete() || len(rep.Stray) != 0 || rep.V != 1 || rep.Host != "h.example.com" || rep.CheckedAt.IsZero() { t.Fatalf("report %+v", rep) } if !g.killed || !g.closed { t.Fatalf("the tree was not killed and closed: %+v", g) } // KillAndEmpty refuses a context with no deadline, so Reconstruct // bounds the drain itself when its caller did not. if !g.deadline { t.Fatal("the drain ran without a deadline") } } func TestReconstructNoHostCgroupIsAnEmptyTree(t *testing.T) { f := newBoxFixture(t) rep, err := reconstruct(context.Background(), f.inputs(), opener(nil, cgroup.ErrNoCgroup)) if err != nil || !rep.Steps[moves.StepTree].OK || !rep.TreeStopped() { t.Fatalf("no cgroup: %+v %v", rep, err) } } func TestReconstructTreeThatWillNotEmpty(t *testing.T) { f := newBoxFixture(t) g := &fakeTree{killErr: context.DeadlineExceeded} rep, err := reconstruct(context.Background(), f.inputs(), opener(g, nil)) if err != nil { t.Fatal(err) } if c := rep.Steps[moves.StepTree]; c.OK || c.Reason != "the instance's processes could not be stopped" || rep.TreeStopped() { t.Fatalf("tree: %+v", rep) } if !g.closed { t.Fatal("the cleanup handle leaked") } // The later steps still ran, so the report says everything it saw. if !rep.Steps[moves.StepStray].OK || !rep.Steps[moves.StepFDs].OK || !rep.Steps[moves.StepLease].OK { t.Fatalf("later steps: %+v", rep.Steps) } } func TestReconstructOpenFailureIsAnError(t *testing.T) { f := newBoxFixture(t) boom := errors.New("boom") if _, err := reconstruct(context.Background(), f.inputs(), opener(nil, boom)); !errors.Is(err, boom) { t.Fatalf("err %v", err) } // A kill that fails for any reason but the drain running out of time // could not observe the tree: an error, not a recorded step. g := &fakeTree{killErr: boom} if _, err := reconstruct(context.Background(), f.inputs(), opener(g, nil)); !errors.Is(err, boom) || !g.closed { t.Fatalf("kill error: %v, closed %v", err, g.closed) } } func TestReconstructCallerCancelledIsAnError(t *testing.T) { f := newBoxFixture(t) ctx, cancel := context.WithCancel(context.Background()) g := &fakeTree{} g.killErr = context.Canceled open := func(string) (treeGroup, error) { cancel(); return g, nil } if _, err := reconstruct(ctx, f.inputs(), open); !errors.Is(err, context.Canceled) { t.Fatalf("err %v", err) } } func TestReconstructStrayInTheAgentsLeaf(t *testing.T) { f := newBoxFixture(t, os.Getpid(), 4243, 4244) rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)) if err != nil { t.Fatal(err) } if c := rep.Steps[moves.StepStray]; c.OK || c.Reason != "pid 4243 in the agent's cgroup is not the agent" || rep.TreeStopped() { t.Fatalf("stray: %+v", rep) } if !reflect.DeepEqual(rep.Stray, []int{4243, 4244}) { t.Fatalf("Stray %v", rep.Stray) } } // The kernel lists pid 0 in cgroup.procs for a member the reader's pid // namespace cannot name (a CI runner's cgroup holds such members). It is // a process this box cannot account for: it fails StepStray with no error, // is never taken for the agent, and is marked in Stray by the 0 entry. func TestReconstructUnidentifiableMemberIsAStray(t *testing.T) { self := os.Getpid() if c := strayCheck([]int{self, 0}, self); c.OK || c.Reason != "1 process(es) in the agent's cgroup cannot be identified" { t.Fatalf("strayCheck with the agent and a 0: %+v", c) } if c := strayCheck([]int{0, self, 0}, self); c.OK || c.Reason != "2 process(es) in the agent's cgroup cannot be identified" { t.Fatalf("strayCheck with two 0s: %+v", c) } f := newBoxFixture(t, self, 0) rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)) if err != nil { t.Fatalf("a pid 0 entry is not a parse error: %v", err) } if c := rep.Steps[moves.StepStray]; c.OK || c.Reason == "" || rep.TreeStopped() { t.Fatalf("stray: %+v", rep) } if !reflect.DeepEqual(rep.Stray, []int{0}) { t.Fatalf("Stray %v", rep.Stray) } // Never the agent itself: a leaf of only 0s does not name the agent. f.setLeaf(t, 0) if _, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err == nil { t.Fatal("a leaf of only pid 0 passed as the agent's") } } // Genuinely malformed entries are still refused. func TestReadProcsRefusesMalformedEntries(t *testing.T) { for _, body := range []string{"abc\n", "-1\n", "-0\n", "12x\n", "1 2\n"} { file := filepath.Join(t.TempDir(), "cgroup.procs") write(t, file, body) if _, err := readProcs(file); err == nil { t.Errorf("%q accepted", body) } } file := filepath.Join(t.TempDir(), "cgroup.procs") write(t, file, "7\n0\n\n9\n") if got, err := readProcs(file); err != nil || !reflect.DeepEqual(got, []int{7, 0, 9}) { t.Fatalf("got %v, %v", got, err) } } // The leaf is read where /proc/self/mountinfo mounts the hierarchy, so a // leaf that names the agent but is not the agent's is an error, not a pass. func TestReconstructRefusesALeafWithoutTheAgent(t *testing.T) { f := newBoxFixture(t, 4243) if _, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err == nil { t.Fatal("a leaf without the agent passed") } } func TestReconstructOwnCgroupUnreadable(t *testing.T) { for name, mutate := range map[string]func(f *boxFixture){ "no v2 line": func(f *boxFixture) { os.WriteFile(filepath.Join(f.proc, "self", "cgroup"), []byte("1:name=systemd:/x\n"), 0o644) }, "root cgroup": func(f *boxFixture) { os.WriteFile(filepath.Join(f.proc, "self", "cgroup"), []byte("0::/\n"), 0o644) }, "no mount": func(f *boxFixture) { os.WriteFile(filepath.Join(f.proc, "self", "mountinfo"), []byte(""), 0o644) }, "no procs": func(f *boxFixture) { os.Remove(filepath.Join(f.leafDir, "cgroup.procs")) }, "garbled pid": func(f *boxFixture) { os.WriteFile(filepath.Join(f.leafDir, "cgroup.procs"), []byte("12x\n"), 0o644) }, "no mountinfo": func(f *boxFixture) { os.Remove(filepath.Join(f.proc, "self", "mountinfo")) }, } { f := newBoxFixture(t) mutate(f) if _, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err == nil { t.Fatalf("%s: passed", name) } } } func TestReconstructDatabaseHolders(t *testing.T) { for _, path := range []string{"", "-wal", "-shm"} { f := newBoxFixture(t) f.holdDB(t, 5150, f.db+path) rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)) if err != nil { t.Fatal(err) } if c := rep.Steps[moves.StepFDs]; c.OK || c.Reason != "1 process(es) hold the database open" || rep.TreeStopped() { t.Fatalf("holder of %q: %+v", path, rep.Steps) } } // A descriptor on some other file is not a holder. f := newBoxFixture(t) other := filepath.Join(filepath.Dir(f.db), "other.db") write(t, other, "x") f.holdDB(t, 5150, other) if rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err != nil || !rep.Steps[moves.StepFDs].OK { t.Fatalf("other file: %+v %v", rep.Steps, err) } } // A descriptor whose file cannot be identified (its stat says ENOENT while // its entry remains: here a dangling fixture link) should not pass step 3. // Main's FDHolders lstats the entry after an ENOENT stat (9cf9f18f), so an // entry that remains but cannot be followed is ErrIncomplete, seen here // through Reconstruct. func TestReconstructUnidentifiedDescriptorIsNotAPass(t *testing.T) { f := newBoxFixture(t) // The database exists, so FDHolders compares identities; a descriptor // whose target cannot be followed is then unreadable, not closed. if err := os.WriteFile(f.db, nil, 0o600); err != nil { t.Fatal(err) } f.holdDB(t, 5150, filepath.Join(filepath.Dir(f.db), "vanished.db")) rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)) if !errors.Is(err, ErrIncomplete) { t.Fatalf("unidentifiable descriptor: %+v %v", rep.Steps, err) } } // Step 3 compares file identity, not path text: a holder of a hard link // to the database (another name, same file) fails StepFDs. func TestReconstructDatabaseHeldThroughAHardLink(t *testing.T) { f := newBoxFixture(t) write(t, f.db, "SQLite format 3") alias := filepath.Join(filepath.Dir(f.db), "..", "alias.db") if err := os.Link(f.db, alias); err != nil { t.Skip("no hard links here:", err) } f.holdDB(t, 5150, filepath.Clean(alias)) rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)) if err != nil { t.Fatal(err) } if c := rep.Steps[moves.StepFDs]; c.OK || c.Reason != "1 process(es) hold the database open" || rep.TreeStopped() { t.Fatalf("hard-link holder passed: %+v", rep.Steps) } } func TestReconstructLeaseStep(t *testing.T) { f := newBoxFixture(t) in := f.inputs() in.LeaseFree = func(context.Context) (bool, error) { return false, nil } rep, err := reconstruct(context.Background(), in, opener(&fakeTree{}, nil)) if err != nil { t.Fatal(err) } if c := rep.Steps[moves.StepLease]; c.OK || c.Reason != "lock.json still held" || rep.TreeStopped() { t.Fatalf("lease held: %+v", rep.Steps) } boom := errors.New("boom") in.LeaseFree = func(context.Context) (bool, error) { return true, boom } if _, err := reconstruct(context.Background(), in, opener(&fakeTree{}, nil)); !errors.Is(err, boom) { t.Fatalf("lease read error: %v", err) } } func TestReconstructInvalidInputs(t *testing.T) { f := newBoxFixture(t) for name, mutate := range map[string]func(*ReconstructInputs){ "bad host": func(in *ReconstructInputs) { in.Host = "../etc" }, "no lease reader": func(in *ReconstructInputs) { in.LeaseFree = nil }, "no database": func(in *ReconstructInputs) { in.DBPaths = nil }, "relative db": func(in *ReconstructInputs) { in.DBPaths = []string{"h.example.com.db"} }, "relative proc": func(in *ReconstructInputs) { in.ProcRoot = "proc" }, "empty db in list": func(in *ReconstructInputs) { in.DBPaths = []string{f.db, ""} }, } { in := f.inputs() mutate(&in) g := &fakeTree{} if _, err := reconstruct(context.Background(), in, opener(g, nil)); !errors.Is(err, ErrInvalidInput) { t.Fatalf("%s: err %v", name, err) } if g.killed { t.Fatalf("%s: killed the tree before refusing the input", name) } } in := f.inputs() if _, err := Reconstruct(context.Background(), in); !errors.Is(err, ErrInvalidInput) { t.Fatalf("nil manager: err %v", err) } if _, err := reconstruct(nil, f.inputs(), opener(&fakeTree{}, nil)); !errors.Is(err, ErrInvalidInput) { //nolint:staticcheck // a nil context is the input under test t.Fatalf("nil context: err %v", err) } } func TestCgroupLeafDir(t *testing.T) { line := func(root, mnt, fstype string) string { return "40 30 0:27 " + root + " " + mnt + " rw,relatime shared:8 - " + fstype + " " + fstype + " rw\n" } for _, c := range []struct { name, mountinfo, leaf, want string ok bool }{ {"root mount", line("/", "/sys/fs/cgroup", "cgroup2"), "a.service/supervise", "/sys/fs/cgroup/a.service/supervise", true}, {"mounted elsewhere", line("/", "/run/cg", "cgroup2"), "a.service/supervise", "/run/cg/a.service/supervise", true}, {"escaped space", line("/", `/run/c\040g`, "cgroup2"), "a/b", "/run/c g/a/b", true}, {"subtree bind", line("/a.service", "/mnt/a", "cgroup2"), "a.service/supervise", "/mnt/a/supervise", true}, {"subtree is the leaf", line("/a.service/supervise", "/mnt/s", "cgroup2"), "a.service/supervise", "/mnt/s", true}, {"longest root wins", line("/", "/sys/fs/cgroup", "cgroup2") + line("/a.service", "/mnt/a", "cgroup2"), "a.service/x", "/mnt/a/x", true}, {"first of equal roots", line("/", "/sys/fs/cgroup", "cgroup2") + line("/", "/other", "cgroup2"), "a/b", "/sys/fs/cgroup/a/b", true}, {"sibling subtree only", line("/a.serviceX", "/mnt/x", "cgroup2"), "a.service/supervise", "", false}, {"v1 only", line("/", "/sys/fs/cgroup/memory", "cgroup"), "a/b", "", false}, {"no mounts", "", "a/b", "", false}, {"outside the namespace", line("/", "/sys/fs/cgroup", "cgroup2"), "../../x", "", false}, {"garbled line", "40 30 0:27 / /sys/fs/cgroup\n", "a/b", "", false}, } { got, err := cgroupLeafDir(c.mountinfo, c.leaf) if c.ok != (err == nil) || got != c.want { t.Fatalf("%s: %q %v, want %q ok=%v", c.name, got, err, c.want, c.ok) } } } // The drain bound never extends the caller's own deadline. func TestReconstructKeepsTheCallersDeadline(t *testing.T) { f := newBoxFixture(t) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() want, _ := ctx.Deadline() var got time.Time open := func(string) (treeGroup, error) { return deadlineTree(func(d time.Time) { got = d }), nil } if _, err := reconstruct(ctx, f.inputs(), open); err != nil { t.Fatal(err) } if got.After(want) || got.IsZero() { t.Fatalf("drain deadline %v, caller's %v", got, want) } } type deadlineTree func(time.Time) func (d deadlineTree) KillAndEmpty(ctx context.Context) error { dl, _ := ctx.Deadline() d(dl) return nil } func (deadlineTree) Close() error { return nil } // The agent's leaf is read again after the descriptor scan and the lease // read, so a process that joins it while those run fails StepStray for // this pass. LeaseFree runs between the two reads, so it is the hook that // adds the member: deterministic, no timing. func TestReconstructStrayJoiningDuringTheScanFails(t *testing.T) { f := newBoxFixture(t) in := f.inputs() in.LeaseFree = func(context.Context) (bool, error) { f.setLeaf(t, os.Getpid(), 4321) return true, nil } rep, err := reconstruct(context.Background(), in, opener(&fakeTree{}, nil)) if err != nil { t.Fatal(err) } if c := rep.Steps[moves.StepStray]; c.OK || c.Reason != "pid 4321 in the agent's cgroup is not the agent" || rep.TreeStopped() { t.Fatalf("a pid that joined during the scan passed: %+v", rep.Steps) } if !reflect.DeepEqual(rep.Stray, []int{4321}) { t.Fatalf("Stray %v", rep.Stray) } // A stray in both reads is listed once. f = newBoxFixture(t, os.Getpid(), 4243) in = f.inputs() in.LeaseFree = func(context.Context) (bool, error) { f.setLeaf(t, os.Getpid(), 4243, 4321) return true, nil } rep, err = reconstruct(context.Background(), in, opener(&fakeTree{}, nil)) if err != nil || rep.Steps[moves.StepStray].OK || !reflect.DeepEqual(rep.Stray, []int{4243, 4321}) { t.Fatalf("stray in both reads: %+v %v %v", rep.Steps, rep.Stray, err) } } // The second read is held to the first's standard: unreadable, or without // the agent, is an error. func TestReconstructSecondLeafReadFailsClosed(t *testing.T) { for name, mutate := range map[string]func(f *boxFixture){ "leaf gone": func(f *boxFixture) { os.Remove(filepath.Join(f.leafDir, "cgroup.procs")) }, "agent gone": func(f *boxFixture) { f.setLeaf(t, 4321) }, "garbled listing": func(f *boxFixture) { write(t, filepath.Join(f.leafDir, "cgroup.procs"), "x\n") }, } { f := newBoxFixture(t) in := f.inputs() in.LeaseFree = func(context.Context) (bool, error) { mutate(f); return true, nil } if _, err := reconstruct(context.Background(), in, opener(&fakeTree{}, nil)); err == nil { t.Fatalf("%s: passed", name) } } }