Artifact d01a1b4ebc8cbd421cbc394e0cd0ed60a81bc0ab8cd376c40191e8b6c73b6034:
- File
internal/checkout/marker_crash_test.go
— part of check-in
[83f574dc8f]
at
2026-10-21 12:20:43
on branch feat/submanifest-reader
— test(checkout): read fossil's listing from stdout in the marker crash test
In the linked crash state both marker names are one file, and SQLite on Linux warns about the multiple links on stderr while fossil lists the record normally. CombinedOutput folded the warning into the file list. (user: [email protected], size: 7689) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "errors" "os" "os/exec" "path/filepath" "slices" "sort" "strings" "testing" "github.com/danmestas/go-libfossil/internal/cone" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/simio" "github.com/danmestas/go-libfossil/testutil" ) type markerCrashed struct{ step string } // crashMarkerAt makes the next marker transition die after step: the panic // unwinds past every cleanup the transition would otherwise run, which is // what a process death leaves on disk. func crashMarkerAt(t *testing.T, step string) { t.Helper() markerCrash = func(at string) { if at == step { panic(markerCrashed{step}) } } t.Cleanup(func() { markerCrash = nil }) } func setConeCrashes(co *Checkout, next *cone.Cone) bool { return crashes(func() { co.SetCone(next) }) } func crashes(run func()) (crashed bool) { defer func() { if r := recover(); r != nil { if _, ok := r.(markerCrashed); !ok { panic(r) } crashed = true } }() run() return false } // Open's migration of a legacy sparse record off the native name is the // third transition; a crash at any step reopens as the migrated checkout. func TestLegacyMigrationCrashRecovers(t *testing.T) { for _, step := range []string{"closed", "linked", "unlinked"} { t.Run(step, func(t *testing.T) { f := newSparseRepo(t, false) c, err := Create(f.repo, t.TempDir(), CreateOpts{}) if err != nil { t.Fatal(err) } if err := c.Extract(f.c1, ExtractOpts{Force: true}); err != nil { t.Fatal(err) } sparse := sparseCone(t) if err := writeCone(c.db, sparse); err != nil { t.Fatal(err) } for name := range sparseFiles { if !sparse.Covers(name) { if err := os.Remove(filepath.Join(c.Dir(), filepath.FromSlash(name))); err != nil { t.Fatal(err) } } } if err := c.Close(); err != nil { t.Fatal(err) } crashMarkerAt(t, step) if !crashes(func() { Open(f.repo, c.Dir(), OpenOpts{}) }) { t.Fatalf("migration never reached step %q", step) } markerCrash = nil reopened, err := Open(f.repo, c.Dir(), OpenOpts{}) if err != nil { t.Fatalf("open after a migration crash at %q: %v", step, err) } defer reopened.Close() assertCheckoutMarker(t, c.Dir(), sparseDBName) checkCrashState(t, reopened, reopened.Cone(), sparse, sparse) }) } } // A crash at any step of a marker transition reopens as the transition's // old or new state, never both and never neither; stock fossil either // refuses the directory or reads the dense record it holds. func TestMarkerTransitionCrashRecovers(t *testing.T) { bin := testutil.FossilBinary() if bin != "" { testutil.IsolateFossilHome(t) } for _, narrowing := range []bool{true, false} { for _, step := range []string{"closed", "linked", "unlinked"} { name := "widen/" + step if narrowing { name = "narrow/" + step } t.Run(name, func(t *testing.T) { testMarkerCrash(t, bin, narrowing, step) }) } } } func testMarkerCrash(t *testing.T, bin string, narrowing bool, step string) { f := newSparseRepo(t, false) var initial, next *cone.Cone if narrowing { next = sparseCone(t) } else { initial = sparseCone(t) } dir := t.TempDir() co, err := Create(f.repo, dir, CreateOpts{Cone: initial}) if err != nil { t.Fatal(err) } if err := co.Extract(f.c1, ExtractOpts{Force: true}); err != nil { t.Fatal(err) } crashMarkerAt(t, step) if !setConeCrashes(co, next) { t.Fatalf("SetCone never reached step %q", step) } markerCrash = nil // fossil cannot read a repository go-libfossil holds open. path := f.repo.Path() f.repo.Close() if bin != "" { checkFossilReadsCrash(t, bin, dir) } r, err := repo.Open(path) if err != nil { t.Fatal(err) } for round := 0; round < 2; round++ { reopened, err := Open(r, dir, OpenOpts{}) if err != nil { t.Fatalf("open after a crash at %q (round %d): %v", step, round, err) } got := reopened.Cone() checkCrashState(t, reopened, got, initial, next) if err := reopened.Close(); err != nil { t.Fatal(err) } } if err := r.Close(); err != nil { t.Fatal(err) } if bin != "" { checkFossilReadsCrash(t, bin, dir) } } func checkCrashState(t *testing.T, co *Checkout, got, initial, next *cone.Cone) { t.Helper() digest := func(c *cone.Cone) string { if c == nil { return "" } return c.Digest() } if digest(got) != digest(initial) && digest(got) != digest(next) { t.Fatalf("reopened cone %v is neither the old nor the new one", got) } marker, err := checkoutMarker(simio.OSStorage{}, co.Dir()) if err != nil { t.Fatalf("after recovery: %v", err) } if got != nil && filepath.Base(marker) != sparseDBName { t.Fatalf("sparse record reopened under native marker %s", marker) } want := sparseCovered if got == nil { want = nil for name := range sparseFiles { want = append(want, name) } sort.Strings(want) } if disk := onDisk(t, co); !slices.Equal(disk, want) { t.Fatalf("on disk %q, want %q for cone %v", disk, want, got) } } // checkFossilReadsCrash runs stock fossil in the recovered directory: with no // native marker it must refuse; with one it must list the dense record. func checkFossilReadsCrash(t *testing.T, bin, dir string) { t.Helper() cmd := exec.Command(bin, "ls") cmd.Dir = dir cmd.Env = append(os.Environ(), "USER=test") // The listing is stdout alone: in the "linked" state both marker names // are one file, and SQLite on Linux warns about that on stderr // ("SQLITE_WARNING(28): file ... has multiple links") while fossil reads // the record all the same. var stderr strings.Builder cmd.Stderr = &stderr out, err := cmd.Output() _, nativeErr := os.Lstat(filepath.Join(dir, PreferredDBName())) if nativeErr != nil { if err == nil || !strings.Contains(stderr.String(), "not within an open check") { t.Fatalf("fossil read a directory with no native marker: %v\n%s%s", err, out, stderr.String()) } return } if err != nil { t.Fatalf("fossil ls over the native marker: %v\n%s%s", err, out, stderr.String()) } listed := strings.Fields(string(out)) sort.Strings(listed) var want []string for name := range sparseFiles { want = append(want, name) } sort.Strings(want) if !slices.Equal(listed, want) { t.Fatalf("fossil lists %q under the native marker, want the dense record %q", listed, want) } } // An Open that finds the pair while another process is still between link // and unlink completes the same move that process is making: the transition // then finishes, and exactly the target name remains. func TestMarkerRecoveryDuringLiveTransition(t *testing.T) { for _, narrowing := range []bool{true, false} { f := newSparseRepo(t, false) var initial, next *cone.Cone want := PreferredDBName() if narrowing { next, want = sparseCone(t), sparseDBName } else { initial = sparseCone(t) } co, err := Create(f.repo, t.TempDir(), CreateOpts{Cone: initial}) if err != nil { t.Fatal(err) } if err := co.Extract(f.c1, ExtractOpts{Force: true}); err != nil { t.Fatal(err) } markerCrash = func(step string) { if step != "linked" { return } _, err := checkoutMarker(simio.OSStorage{}, co.Dir()) var pair *markerPairError if !errors.As(err, &pair) { t.Fatalf("no linked pair at the linked step: %v", err) } if err := recoverMarkerPair(pair); err != nil { t.Fatalf("concurrent recovery: %v", err) } } _, err = co.SetCone(next) markerCrash = nil if err != nil { t.Fatalf("narrowing=%v: transition after a concurrent recovery: %v", narrowing, err) } assertCheckoutMarker(t, co.Dir(), want) if err := co.Close(); err != nil { t.Fatal(err) } } }