Skip to content
go-libfossil Artifact [a3416208b0]
Login

Artifact [a3416208b0]

Artifact a3416208b03417f0b7d08d84d968c9c642b61f39a3367fa14a52a014ce62a9a5:


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")
	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)
		}
	}
}