Artifact 1095854ed97038a7596aa6ef66f5166f4cc8908cbc6f4484a943777f1253556e:
- File
internal/sync/clone_realfossil_delta_test.go
— part of check-in
[67bc700498]
at
2026-10-24 02:58:56
on branch main
— fix(sync): clone an origin that holds a dangling delta
An origin that held a delta whose source it never received could not be cloned at all. walkCloneChain found the source unforwardable (a phantom) and asked buildCloneArtifact to send the delta expanded; expanding it failed, the handler errored, and every client stopped at clone sequence 1.
The origin now withholds a chain that ends at a phantom, as it withholds a row outside the clone's cone. Sending the stored delta naming its phantom source, as fossil's send_compressed_file does, is not enough: a fossil client then rejects the whole clone for its unresolved deltas. A later pull fetches the chain once the source arrives. (user: [email protected], size: 7810) [annotate] [blame] [check-ins using] [more...]
package sync_test import ( "context" "fmt" "os/exec" "path/filepath" "strconv" "strings" "testing" "github.com/danmestas/go-libfossil/internal/auth" "github.com/danmestas/go-libfossil/internal/delta" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/sync" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/simio" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/testutil" ) // TestCloneRealFossilWithDeltaChain drives a fixed corpus (a linear delta // chain) from this server through a real fossil binary and checks that the // clone is actually usable, not merely that the `fossil clone` subprocess // exits 0. It is skipped, not failed, when no fossil binary is on PATH. // // #141 changed how a deltified row is sent during clone: instead of expanding // the delta and shipping full content, it retransmits the row as a delta and // emits the delta's source ahead of it (buildCloneArtifact walks the chain // source-first). content.Deltify deltifies the OLDER artifact against the // NEWER one, so a delta's source almost always has a *greater* rid than the // delta itself and, under the send loop's ascending-rid order, would not yet // have been sent; walking source-first guarantees no delta forward-references // a card that has not arrived. Since #255 the delta rides a "cfile UUID // DELTASRC USIZE CSIZE" card, as canonical's send_compressed_file sends it in // a version-3 clone, and a real fossil client stores the payload verbatim. // // Full content rides a plain "cfile" card. #152 fixed the cfile wire // framing: the payload is now fossil's on-disk blob format ([4-byte // big-endian size][zlib]), the exact bytes blob_compress() produces, so a real // fossil client stores it verbatim and later reads it back through // blob_uncompress() successfully. Before that fix go-libfossil emitted bare // zlib (no prefix), so full content decoded to garbage and rebuilt to zero // check-ins. This test asserts real usability -- it clones, rebuilds, and // requires all six check-ins to materialize. func TestCloneRealFossilWithDeltaChain(t *testing.T) { bin := testutil.RequireFossilBin(t) dir := t.TempDir() srcRepo, err := repo.Create(filepath.Join(dir, "source.fossil"), "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } defer srcRepo.Close() // Six revisions of one file, each a small edit of the last, chained via // Parent so mlink resolves a predecessor for content.Deltify to act on. // Small edits keep the delta well under deltifyMinRatio, so every // revision but the last is actually stored as a delta. const wantCheckins = 6 base := make([]byte, 4096) for i := range base { base[i] = byte(i % 251) } var parent libfossil.FslID for c := 0; c < wantCheckins; c++ { content := append([]byte(nil), base...) content[c] = byte(200 + c) content = append(content, []byte(fmt.Sprintf("\nrevision marker %d\n", c))...) mid, _, err := manifest.Checkin(srcRepo, manifest.CheckinOpts{ Comment: fmt.Sprintf("checkin %d", c), User: "testuser", Parent: parent, Files: []manifest.File{ {Name: "big.bin", Content: content}, }, }) if err != nil { t.Fatalf("Checkin %d: %v", c, err) } parent = mid } if _, err := manifest.Crosslink(srcRepo); err != nil { t.Fatalf("Crosslink: %v", err) } var deltaCount int if err := srcRepo.DB().QueryRow("SELECT count(*) FROM delta").Scan(&deltaCount); err != nil { t.Fatalf("count deltas: %v", err) } if deltaCount == 0 { t.Fatal("fixture bug: no deltas were created in the source repo -- this test needs a deltified chain to be meaningful") } ctx, cancel := context.WithCancel(context.Background()) defer cancel() url := serveRepo(ctx, t, srcRepo) clonePath := filepath.Join(dir, "clone.fossil") cmd := exec.Command(bin, "clone", url, clonePath) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("real fossil clone of a %d-deep delta chain failed: %v\n%s", deltaCount, err, out) } // A clone that exits 0 is necessary but not sufficient: a real fossil client // reports success and can still hold unusable content. Rebuild forces it to // decode and verify every artifact; counting check-ins afterward proves the // content actually materialized. Asserting only on the clone exit code (as // this test originally did) proves nothing but that the subprocess ran. if out, err := exec.Command(bin, "rebuild", clonePath).CombinedOutput(); err != nil { t.Fatalf("fossil rebuild of the clone failed: %v\n%s", err, out) } got := fossilCheckinCount(t, bin, clonePath) if got != wantCheckins { t.Fatalf("clone materialized %d check-ins, want %d (#152: cfile framing regression?)", got, wantCheckins) } } // TestCloneRealFossilDanglingDelta clones, with a real fossil binary, an // origin that holds a delta whose source it never received. fossil rejects // a clone left with unresolved deltas, so the origin withholds the delta it // cannot expand, and fossil's clone succeeds without it. func TestCloneRealFossilDanglingDelta(t *testing.T) { bin := testutil.RequireFossilBin(t) dir := t.TempDir() srcRepo, err := repo.Create(filepath.Join(dir, "source.fossil"), "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } defer srcRepo.Close() if _, _, err := manifest.Checkin(srcRepo, manifest.CheckinOpts{ Comment: "first", User: "testuser", Files: []manifest.File{{Name: "a.txt", Content: []byte("a\n")}}, }); err != nil { t.Fatalf("Checkin: %v", err) } if err := auth.UpdateCaps(srcRepo.DB(), "nobody", "gioi"); err != nil { t.Fatalf("caps: %v", err) } projectCode, err := srcRepo.Config("project-code") if err != nil { t.Fatalf("project-code: %v", err) } base, target := []byte("missing base\n"), []byte("target content\n") d, err := delta.Create(base, target) if err != nil { t.Fatal(err) } if _, err := sync.HandleSync(context.Background(), srcRepo, &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "s", ProjectCode: projectCode}, &xfer.FileCard{UUID: hash.SHA1(target), DeltaSrc: hash.SHA1(base), Content: d}, }}); err != nil { t.Fatalf("push dangling delta: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() url := serveRepo(ctx, t, srcRepo) clonePath := filepath.Join(dir, "clone.fossil") cmd := exec.Command(bin, "clone", url, clonePath) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("real fossil clone of an origin with a dangling delta failed: %v\n%s", err, out) } out, err := exec.Command(bin, "sql", "-R", clonePath, "SELECT count(*) FROM blob WHERE uuid='"+hash.SHA1(target)+"';").Output() if err != nil { t.Fatalf("reading the clone: %v", err) } if got := strings.TrimSpace(string(out)); got != "0" { t.Fatalf("the clone holds the dangling delta (%s rows); it should be withheld", got) } if got := fossilCheckinCount(t, bin, clonePath); got != 1 { t.Fatalf("clone has %d check-ins, want 1", got) } } // fossilCheckinCount returns the number of check-in events a real fossil repo // holds, via `fossil sql`. Used to prove a clone is actually populated. func fossilCheckinCount(t *testing.T, bin, repoPath string) int { t.Helper() out, err := exec.Command(bin, "sql", "-R", repoPath, "SELECT count(*) FROM event WHERE type='ci';").Output() if err != nil { t.Fatalf("counting check-ins in clone: %v", err) } n, err := strconv.Atoi(strings.TrimSpace(string(out))) if err != nil { t.Fatalf("parsing check-in count %q: %v", string(out), err) } return n }