Artifact 252b4e13874fcff0d94dde21da0c36c55d12e8c28c5f3b14fff118d985d9a726:
- File
cli/clone_fidelity_test.go
— part of check-in
[a02c9572da]
at
2026-07-23 16:18:24
on branch trunk
— clone: add --timeout override and make the deadline reliably fire (#120) (#144)
The clone command hardcoded a 10-minute context deadline with no way to raise or disable it, and the deadline did not reliably interrupt a slow clone.
- RepoCloneCmd gains a --timeout flag (default 10m, matching the old behavior; 0 disables the deadline for very large repositories). cloneContext branches away from context.WithTimeout for a non-positive timeout -- WithTimeout(parent, 0) yields an already-expired deadline, which would abort instantly the very clone the flag exists to unbound.
- The whole-repository crosslink sweep now observes context cancellation. It runs after the exchange-round loop, so it had no round boundary of its own at which the deadline could fire -- on a large repository it could run long past the deadline uninterrupted. manifest.CrosslinkContext checks the context every 256 candidates; manifest.Crosslink stays a background-context wrapper so its ~30 existing callers are unchanged. processResponse gains the same batched check so a large single-round batch is interruptible too, and the sync session's and the serve handler's post-convergence sweeps are wired to their contexts for the same reason.
Each cancellation point is pinned by a test that fails without it: the crosslink and processResponse tests drive a context that survives the first poll and dies before the second, so only the batched check can observe it; the crosslink-sweep test cancels after a zero-card final round, so nothing before the sweep is left to notice.
Also adds a clone -> serve -> reclone fidelity regression guard (#114), built with the canonical fossil binary rather than this library's Commit. Commit does not deltify, so a corpus built through it holds zero delta rows and never reaches the serve side's deltified-row expansion branch -- a green result from such a corpus proves nothing about #114. The guard now asserts the source holds delta rows before measuring anything, and compares the second-generation clone's tip, artifact count, blob-store size, delta-row count and checked-out tree content against the source.
Measured against that corpus (71 artifacts, 54 delta rows), #114 does not reproduce: gen1 and gen2 both hold the same 54 delta rows, the same tip and artifact count, a 1.009 store ratio, identical checked-out content, and pass fossil test-integrity. The receive path re-deltifies what the serve side expanded, via content.Deltify on the crosslink mlink path, so the expansion the serve side performs does not survive into the store. (user: [email protected], size: 12613) [annotate] [blame] [check-ins using]
package cli_test import ( "bytes" "context" "fmt" "net" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" "time" libfossil "github.com/danmestas/go-libfossil" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // serveRepoCtx starts this library's serve stack over repoPath and returns its // address once it accepts connections, shutting the server down via context // cancellation at test end. Unlike serveRepo it does not signal the process, // so it is safe to stand up more than one server within a single test (a // clone -> serve -> reclone chain needs exactly that). See the SIGINT caveat // at the top of repo_serve_test.go. func serveRepoCtx(t *testing.T, repoPath string) string { t.Helper() r, err := libfossil.Open(repoPath) if err != nil { t.Fatalf("Open %s: %v", repoPath, err) } addr := freeAddr(t) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- r.ServeHTTP(ctx, addr) }() t.Cleanup(func() { cancel() <-done r.Close() }) deadline := time.Now().Add(10 * time.Second) for { conn, dialErr := net.DialTimeout("tcp", addr, 200*time.Millisecond) if dialErr == nil { conn.Close() return addr } if time.Now().After(deadline) { t.Fatalf("server never came up: %v", dialErr) } time.Sleep(20 * time.Millisecond) } } // repoSQLInt runs a scalar integer query against repoPath through the canonical // fossil binary's sqlite3 shell. func repoSQLInt(t *testing.T, bin, repoPath, query string) int64 { t.Helper() out, err := exec.Command(bin, "sqlite3", "--no-repository", repoPath, query).Output() if err != nil { t.Fatalf("sqlite3 %q on %s: %v", query, repoPath, err) } n, err := strconv.ParseInt(string(bytes.TrimSpace(out)), 10, 64) if err != nil { t.Fatalf("unparseable result %q for %q: %v", out, query, err) } return n } // fossilTipUUID returns the UUID of the newest check-in in repoPath, as the // canonical fossil binary sees it. Comparing this across clone generations is // the "pinned tip" check #114 asks for: a second-generation clone that // diverges reports a different tip here. func fossilTipUUID(t *testing.T, bin, repoPath string) string { t.Helper() out, err := exec.Command(bin, "sqlite3", "--no-repository", repoPath, "SELECT uuid FROM blob b JOIN event e ON e.objid=b.rid "+ "WHERE e.type='ci' ORDER BY e.mtime DESC, b.rid DESC LIMIT 1;").Output() if err != nil { t.Fatalf("tip uuid in %s: %v", repoPath, err) } return string(bytes.TrimSpace(out)) } // blobStoreBytes reports the total size of the blob content column, which is // the store size #114 measures ("store balloons 5x"). File size alone includes // SQLite free pages, so SUM(length(content)) is the faithful proxy for how // much blob payload a repository actually holds on disk. func blobStoreBytes(t *testing.T, bin, repoPath string) int64 { t.Helper() return repoSQLInt(t, bin, repoPath, "SELECT COALESCE(SUM(length(content)),0) FROM blob;") } // deltaRowCount reports how many blobs are stored as deltas against another // blob. This is the number that decides whether a corpus exercises the serve // side's deltified-row path at all: the handler only takes its expand branch // for rows content.DeltaSource names, so a corpus with zero delta rows makes // every fidelity assertion below vacuous for that path. func deltaRowCount(t *testing.T, bin, repoPath string) int64 { t.Helper() return repoSQLInt(t, bin, repoPath, "SELECT COUNT(*) FROM delta;") } // evolvingFileContent is the content of one file at one generation: a large, // highly compressible body that grows by one line per generation, so each // version differs from its predecessor by a little. This is what makes the // canonical fossil binary store the older version as a delta against the newer // one -- the delta-chain content #114 worried a re-clone would re-expand and // never re-encode. func evolvingFileContent(file, generations int) string { var sb strings.Builder for line := range 900 { fmt.Fprintf(&sb, "file %02d line %05d: the quick brown fox jumps over the lazy dog\n", file, line) } for gen := range generations { fmt.Fprintf(&sb, "appended generation %d for file %02d\n", gen, file) } return sb.String() } // buildDeltaBearingRepo builds a repository with the canonical fossil binary // and returns its path alongside the final content of every file it holds. // // The fossil binary is used deliberately rather than this library's own Commit: // Commit does not deltify, so a corpus built through it holds zero delta rows // and cannot exercise the serve side's deltified-row path at all. Building the // source the way a real fossil repository is built is what makes this test a // real test of #114 rather than a test of the verbatim path twice. func buildDeltaBearingRepo(t *testing.T, bin, dir string, fileCount, generations int) (string, map[string]string) { t.Helper() repoPath := filepath.Join(dir, "source.fossil") work := filepath.Join(dir, "source-work") if err := os.MkdirAll(work, 0o755); err != nil { t.Fatalf("mkdir %s: %v", work, err) } run := func(wd string, args ...string) { t.Helper() cmd := exec.Command(bin, args...) cmd.Dir = wd if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("fossil %v in %s: %v\n%s", args, wd, err, out) } } run(dir, "init", repoPath) run(work, "open", repoPath) for gen := 1; gen <= generations; gen++ { for file := range fileCount { name := fmt.Sprintf("file%03d.txt", file) body := evolvingFileContent(file, gen) if err := os.WriteFile(filepath.Join(work, name), []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } if gen == 1 { run(work, "add", name) } } run(work, "commit", "-m", fmt.Sprintf("generation %d", gen), "--no-warnings") } run(work, "close") want := make(map[string]string, fileCount) for file := range fileCount { want[fmt.Sprintf("file%03d.txt", file)] = evolvingFileContent(file, generations) } return repoPath, want } // goClone clones srcPath (served by this library's own serve stack) into // dstPath using this library's own clone, returning how long the clone took. func goClone(t *testing.T, srcPath, dstPath string) time.Duration { t.Helper() addr := serveRepoCtx(t, srcPath) transport := libfossil.NewHTTPTransport("http://" + addr) start := time.Now() r, _, err := libfossil.Clone(context.Background(), dstPath, transport, libfossil.CloneOpts{}) elapsed := time.Since(start) if err != nil { t.Fatalf("go clone %s -> %s: %v", srcPath, dstPath, err) } r.Close() return elapsed } // assertCheckoutMatches opens repoPath with the canonical fossil binary and // compares every checked-out file against want. #114's primary reported defect // was wrong *checked-out tree content*, which no store-size or tip comparison // can detect: a repository can hold the right artifact count and still hand a // checkout the wrong bytes. func assertCheckoutMatches(t *testing.T, bin, repoPath string, want map[string]string) { t.Helper() work := filepath.Join(t.TempDir(), "checkout") if err := os.MkdirAll(work, 0o755); err != nil { t.Fatalf("mkdir %s: %v", work, err) } cmd := exec.Command(bin, "open", repoPath) cmd.Dir = work if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("fossil open %s: %v\n%s", repoPath, err, out) } entries, err := os.ReadDir(work) if err != nil { t.Fatalf("read checkout dir: %v", err) } checkedOut := map[string]bool{} for _, e := range entries { if e.IsDir() || strings.HasPrefix(e.Name(), ".fslckout") || strings.HasPrefix(e.Name(), "_FOSSIL_") { continue } checkedOut[e.Name()] = true } for name, wantBody := range want { if !checkedOut[name] { t.Errorf("checkout of %s is missing %s", repoPath, name) continue } got, err := os.ReadFile(filepath.Join(work, name)) if err != nil { t.Errorf("read %s from checkout: %v", name, err) continue } if string(got) != wantBody { t.Errorf("checked-out %s differs from source: got %d bytes, want %d bytes", name, len(got), len(wantBody)) } } for name := range checkedOut { if _, ok := want[name]; !ok { t.Errorf("checkout of %s holds unexpected file %s", repoPath, name) } } } // TestCloneServeReclone is the regression guard for #114: clone a corpus, serve // that clone with this library, reclone from it, and compare the second- // generation clone's tip, artifact count, blob-store size and checked-out tree // content against the source. #114 reported the store ballooning ~5x with a // diverged tip and wrong checked-out content. // // The source is built with the canonical fossil binary specifically so it holds // delta rows, which the test asserts before measuring anything. That assertion // is the point: the serve side expands a deltified row and sends it as a plain // cfile (see the comment at internal/sync/handler.go's clone batch loop), so a // corpus with no delta rows never reaches that branch and every number below // would come from the verbatim path alone -- a green result proving nothing. func TestCloneServeReclone(t *testing.T) { bin := requireFossilBin(t) tmp := t.TempDir() srcPath, wantTree := buildDeltaBearingRepo(t, bin, tmp, 6, 10) srcDeltas := deltaRowCount(t, bin, srcPath) if srcDeltas == 0 { t.Fatalf("source corpus holds no delta rows: the serve side's "+ "deltified-row path is never exercised, so this test would pass "+ "without testing what #114 is about (blobs=%d)", artifactCount(t, bin, srcPath)) } srcArtifacts := artifactCount(t, bin, srcPath) srcTip := fossilTipUUID(t, bin, srcPath) srcStore := blobStoreBytes(t, bin, srcPath) t.Logf("SOURCE: artifacts=%d deltas=%d tip=%s store=%d bytes", srcArtifacts, srcDeltas, srcTip, srcStore) gen1Path := filepath.Join(tmp, "gen1.fossil") gen1Dur := goClone(t, srcPath, gen1Path) gen1Artifacts := artifactCount(t, bin, gen1Path) gen1Deltas := deltaRowCount(t, bin, gen1Path) gen1Tip := fossilTipUUID(t, bin, gen1Path) gen1Store := blobStoreBytes(t, bin, gen1Path) t.Logf("GEN1: artifacts=%d deltas=%d tip=%s store=%d bytes cloned in %s", gen1Artifacts, gen1Deltas, gen1Tip, gen1Store, gen1Dur) gen2Path := filepath.Join(tmp, "gen2.fossil") gen2Dur := goClone(t, gen1Path, gen2Path) gen2Artifacts := artifactCount(t, bin, gen2Path) gen2Deltas := deltaRowCount(t, bin, gen2Path) gen2Tip := fossilTipUUID(t, bin, gen2Path) gen2Store := blobStoreBytes(t, bin, gen2Path) t.Logf("GEN2: artifacts=%d deltas=%d tip=%s store=%d bytes cloned in %s", gen2Artifacts, gen2Deltas, gen2Tip, gen2Store, gen2Dur) if srcStore > 0 { t.Logf("STORE RATIO gen1/source=%.3f gen2/source=%.3f", float64(gen1Store)/float64(srcStore), float64(gen2Store)/float64(srcStore)) } if integ, err := exec.Command(bin, "test-integrity", "-R", gen2Path).CombinedOutput(); err != nil { t.Errorf("gen2 fossil test-integrity failed: %v\n%s", err, integ) } if gen1Tip != srcTip { t.Errorf("gen1 tip %s diverged from source tip %s", gen1Tip, srcTip) } if gen2Tip != srcTip { t.Errorf("gen2 tip %s diverged from source tip %s", gen2Tip, srcTip) } if gen1Artifacts != srcArtifacts { t.Errorf("gen1 holds %d artifacts, source held %d", gen1Artifacts, srcArtifacts) } if gen2Artifacts != srcArtifacts { t.Errorf("gen2 holds %d artifacts, source held %d", gen2Artifacts, srcArtifacts) } // A clone must re-deltify what it receives. The serve side hands over every // deltified row expanded, so a receiver that did not re-encode would hold a // fully expanded store -- #114's balloon. Losing this is the specific // regression that would make the ratio assertions below start failing. if gen1Deltas == 0 { t.Errorf("gen1 holds no delta rows though the source held %d; the "+ "clone receive path stopped re-deltifying", srcDeltas) } if gen2Deltas == 0 { t.Errorf("gen2 holds no delta rows though the source held %d; the "+ "clone receive path stopped re-deltifying", srcDeltas) } // A second-generation clone must not balloon the store. Allow a small // margin for legitimate re-compression differences; #114 saw ~5x. if srcStore > 0 && float64(gen1Store) > 1.5*float64(srcStore) { t.Errorf("gen1 store %d ballooned past 1.5x source store %d", gen1Store, srcStore) } if srcStore > 0 && float64(gen2Store) > 1.5*float64(srcStore) { t.Errorf("gen2 store %d ballooned past 1.5x source store %d", gen2Store, srcStore) } // The defect #114 named first: what a checkout of the re-cloned repository // actually contains. assertCheckoutMatches(t, bin, gen2Path, wantTree) }