Artifact 6c5bf378a5511a558627608fbfb54b8e282291bb7ac62b797b6b848c4ab4b28b:
- File
internal/checkout/marker.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: 5460) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "errors" "fmt" "os" "path/filepath" "github.com/danmestas/go-libfossil/db" ) // markerCrash is nil outside tests, which set it to simulate a process // death after a named step of a marker transition. var markerCrash func(step string) func markerStep(step string) { if markerCrash != nil { markerCrash(step) } } // markerMoveVVar records the name a marker transition is moving to. It is // written before linking, so two names for one database always say which // one the transition keeps. const markerMoveVVar = "libfossil-marker-move" // moveMarker moves the SQLite database, not working files in Env.Storage. // Closing after a complete checkpoint prevents connections or WAL sidecars // from continuing to write under the old name. Linking refuses replacement; // interruption before unlink leaves two names for one database, which Open // completes to the recorded target (recoverMarkerPair). Open completing it // concurrently removes the old name first, which counts as done here. func (c *Checkout) moveMarker(name string) error { target := filepath.Join(filepath.Dir(c.dbPath), name) if target == c.dbPath { return nil } if err := checkMarkerTarget(target); err != nil { return err } if err := setVVar(c.db, markerMoveVVar, name); err != nil { return err } var busy, log, checkpointed int if err := c.db.QueryRow("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &log, &checkpointed); err != nil { return err } if busy != 0 { return fmt.Errorf("checkout marker checkpoint is busy") } if err := c.db.Close(); err != nil { return err } markerStep("closed") moveErr := c.linkMarker(target) reopened, err := db.OpenSQL(c.dbPath, db.OpenConfig{}, nil) if err == nil { err = reopened.Ping() } if err != nil { if reopened != nil { reopened.Close() } return errors.Join(moveErr, fmt.Errorf("reopen checkout marker: %w", err)) } c.db = reopened return moveErr } func (c *Checkout) linkMarker(target string) error { old := c.dbPath if err := os.Link(old, target); err != nil { return err } markerStep("linked") if err := syncMarkerDirectory(filepath.Dir(old)); err != nil { return errors.Join(err, os.Remove(target)) } if err := os.Remove(old); err != nil && !os.IsNotExist(err) { return errors.Join(err, os.Remove(target)) } c.dbPath = target err := syncMarkerDirectory(filepath.Dir(old)) markerStep("unlinked") return err } func syncMarkerDirectory(dir string) error { f, err := os.Open(dir) if err != nil { return err } return errors.Join(f.Sync(), f.Close()) } func checkMarkerTarget(target string) error { // Opening SQLite can overwrite or remove sidecars even when its main file // does not exist. Lstat also refuses dangling links and filesystem aliases. for _, suffix := range []string{"", "-wal", "-shm", "-journal"} { name := target + suffix if _, err := os.Lstat(name); err == nil { return fmt.Errorf("checkout marker already exists: %s", name) } else if !os.IsNotExist(err) { return err } } return nil } // markerPairError is one checkout database under both the native and the // private name: what a crash between moveMarker's link and unlink leaves. type markerPairError struct{ native, private string } func (e *markerPairError) Error() string { return fmt.Sprintf("multiple checkout markers in %s: %s and %s (one database)", filepath.Dir(e.native), e.native, e.private) } // linkedMarkerPair reports a and b as a pair when they are the native and the // private name of one file. Distinct files under both names stay ambiguous. func linkedMarkerPair(a, b string) *markerPairError { native, private := a, b if filepath.Base(native) == sparseDBName { native, private = b, a } if filepath.Base(private) != sparseDBName || filepath.Base(native) == sparseDBName { return nil } ni, nerr := os.Lstat(native) pi, perr := os.Lstat(private) if nerr != nil || perr != nil || !ni.Mode().IsRegular() || !os.SameFile(ni, pi) { return nil } return &markerPairError{native: native, private: private} } // recoverMarkerPair completes the interrupted move to the name moveMarker // recorded before linking: the same survivor a still-running transition // keeps, so the two never remove both names. Every transition links while // the record is dense or already under its legacy native name, so the // survivor is never a new sparse record under the name stock fossil opens. func recoverMarkerPair(p *markerPairError) error { if filepath.Dir(p.native) != filepath.Dir(p.private) { panic("checkout.recoverMarkerPair: names in different directories") } ckdb, err := db.OpenSQL(p.private, db.OpenConfig{}, nil) if err != nil { return err } target, err := getVVar(ckdb, markerMoveVVar) sparse, coneErr := readCone(ckdb) if err := errors.Join(err, coneErr, ckdb.Close()); err != nil { return fmt.Errorf("recover checkout marker: %w", err) } drop := p.private switch target { case filepath.Base(p.private): drop = p.native case filepath.Base(p.native): default: if sparse != nil { drop = p.native } } if err := os.Remove(drop); err != nil && !os.IsNotExist(err) { return fmt.Errorf("recover checkout marker: %w", err) } if err := syncMarkerDirectory(filepath.Dir(drop)); err != nil { return fmt.Errorf("recover checkout marker: %w", err) } if err := checkMarkerTarget(drop); err != nil { return fmt.Errorf("recover checkout marker: stale sidecar: %w", err) } return nil }