Artifact 0b117ee8277032fc8e0cff0109beb99cc6b1c11c04ec85ed98addd2507965aab:
- File
internal/checkout/marker_crash_test.go
— part of check-in
[bb9805a794]
at
2026-10-21 11:58:06
on branch feat/submanifest-reader
— fix(checkout): recover a checkout marker move a crash interrupted
A crash between moveMarker's link and unlink left one checkout database under both the native and the private name, which discovery refused, so the checkout could not be opened again. moveMarker now records its target name in the database before linking, and Open completes the move to that name. A still-running transition keeps the same name, so an Open racing it converges instead of removing both names, and moveMarker treats an old name already removed as done.
Crash injection at every step of narrowing, widening and legacy migration reopens as the old or new state with files matching the cone; stock fossil either refuses the directory or lists the dense record, before and after recovery. Clears the SC-4.1-2 scope note. (user: [email protected], size: 7333) [annotate] [blame] [check-ins using]
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() 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(), "HOME="+t.TempDir(), "USER=test") out, err := cmd.CombinedOutput() _, nativeErr := os.Lstat(filepath.Join(dir, PreferredDBName())) if nativeErr != nil { if err == nil || !strings.Contains(string(out), "not within an open check") { t.Fatalf("fossil read a directory with no native marker: %v\n%s", err, out) } return } if err != nil { t.Fatalf("fossil ls over the native marker: %v\n%s", err, out) } 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) } } }