//go:build linux package hostexec import ( "context" "errors" "fmt" "github.com/carlosframework/platform/internal/hostfloor" "os" "path/filepath" "testing" "time" ) func TestPlan9PendingRecovery(t *testing.T) { for _, tc := range []struct { name string boot string start string probeErr error state string legacy bool busy bool ok bool }{ {name: "dead", boot: "boot", probeErr: os.ErrNotExist, state: "pending", ok: true}, {name: "reused-pid", boot: "boot", start: "456", state: "pending", ok: true}, {name: "new-boot", boot: "next", start: "123", state: "pending", ok: true}, {name: "live", boot: "boot", start: "123", state: "pending"}, {name: "unreadable", boot: "boot", probeErr: os.ErrPermission, state: "pending"}, {name: "active", boot: "next", state: "active"}, {name: "legacy", boot: "next", state: "pending", legacy: true}, {name: "lock-held", boot: "next", state: "pending", busy: true}, } { t.Run(tc.name, func(t *testing.T) { cfg, _, _, _, _ := fixture(t) d, err := openLocal(cfg.Guard.DataDir) if err != nil { t.Fatal(err) } defer d.close() s := State{V: 1, Binding: cfg.Binding, State: tc.state, Expires: time.Now().Add(TTL), SafeUntil: 99, LeaseETag: "tag", Process: ControllerProcess{KernelBootID: "boot", PID: 123, Start: "123"}} s.ControllerID = "dead-controller" if tc.legacy { s.Process = ControllerProcess{} } if err = d.write(s); err != nil { t.Fatal(err) } if tc.busy { unlock, e := tryAcquire(cfg.LockDir, s.Host) if e != nil { t.Fatal(e) } defer unlock() } archive := filepath.Join(t.TempDir(), "settled") err = recoverPending(context.Background(), cfg.Guard, cfg.LockDir, archive, func() (string, error) { return tc.boot, nil }, func(int) (string, error) { return tc.start, tc.probeErr }) if tc.ok { if err != nil { t.Fatal(err) } if _, err = d.read(s.Host); !errors.Is(err, os.ErrNotExist) { t.Fatalf("pending retained: %v", err) } entries, err := os.ReadDir(archive) if err != nil || len(entries) != 1 { t.Fatalf("missing durable evidence: %v %v", entries, err) } // Model the crash after archive fsync but before the pending unlink. if err = d.write(s); err != nil { t.Fatal(err) } if err = recoverPending(context.Background(), cfg.Guard, cfg.LockDir, archive, func() (string, error) { return tc.boot, nil }, func(int) (string, error) { return tc.start, tc.probeErr }); err != nil { t.Fatal(err) } } else { if err == nil { t.Fatal("unproven pending controller settled") } got, e := d.read(s.Host) if e != nil || got.ControllerID != s.ControllerID { t.Fatalf("refusal changed record: %+v %v", got, e) } } }) } } func TestPlan9PendingCapturesKernelIdentity(t *testing.T) { p, err := currentControllerProcess() if err != nil { t.Fatal(err) } boot, err := BootID() if err != nil { t.Fatal(err) } start, err := controllerProcessStart(os.Getpid()) if err != nil { t.Fatal(err) } if p.PID != os.Getpid() || p.Start != start || p.KernelBootID != boot { t.Fatalf("wrong process: %+v", p) } if err := proveControllerDead(p, boot, controllerProcessStart); err == nil { t.Fatal("current process considered dead") } } func TestPlan9FailedOpenAbortsOnlyJoinedPending(t *testing.T) { for _, failedCleanup := range []bool{false, true} { t.Run(fmt.Sprint(failedCleanup), func(t *testing.T) { cfg, _, _, _, _ := fixture(t) cfg.Clock = SystemClock{} // Persisted JSON strips monotonic time metadata. cfg.UpdateGuardDir = t.TempDir() cfg.UpdateJournal = filepath.Join(t.TempDir(), "journal.json") cfg.verifyRunning = func(context.Context) error { return hostfloor.ErrFloor } if failedCleanup { cfg.Cleanup = func(context.Context) error { return errors.New("cleanup uncertain") } } c, err := Open(t.Context(), cfg) if c != nil || !errors.Is(err, hostfloor.ErrFloor) { t.Fatal(c, err) } d, err := openLocal(cfg.Guard.DataDir) if err != nil { t.Fatal(err) } defer d.close() state, err := d.read(cfg.Binding.Host) if failedCleanup { if err != nil || state.State != "pending" { t.Fatal(state, err) } } else if !errors.Is(err, os.ErrNotExist) { t.Fatalf("joined aborted Open retained live-process pending: %+v %v", state, err) } }) } } func TestPlan9PendingRecoveryDoesNotCreateLocks(t *testing.T) { cfg, _, _, _, _ := fixture(t) d, err := openLocal(cfg.Guard.DataDir) if err != nil { t.Fatal(err) } defer d.close() s := State{V: 1, Binding: cfg.Binding, State: "pending", Expires: time.Now().Add(TTL), SafeUntil: 99, Process: ControllerProcess{KernelBootID: "old", PID: 123, Start: "123"}} s.ControllerID = "dead" if err = d.write(s); err != nil { t.Fatal(err) } lockDir := filepath.Join(t.TempDir(), "absent") if err = recoverPending(t.Context(), cfg.Guard, lockDir, filepath.Join(t.TempDir(), "settled"), func() (string, error) { return "next", nil }, controllerProcessStart); err != nil { t.Fatal(err) } if _, err = os.Stat(lockDir); !errors.Is(err, os.ErrNotExist) { t.Fatal("root created runtime locks", err) } }