Artifact 747fe57fccd1b5848d4a15a82a7903c218fbba98c0352478292b2a780bf192de:
- File
dst/clone_test.go
— part of check-in
[d3fb5397c8]
at
2026-10-13 15:15:43
on branch main
— fix(sync): answer a client clone_seqno with bad command; make Clone's phantom pull opt-in (#427)
* fix(sync): answer a client clone_seqno with bad command; make Clone's phantom pull opt-in
Two deliberate #255 deviations from sync-protocol-01 move to conformance.
SP-5.2-6: the server now answers a client-sent clone_seqno with "error bad command" and processes the cards after it, as Section 5.2 requires for a reply-only card and as fossil's page_xfer does.
SP-8.2-5: Clone now sends nothing after clone_seqno 0 and its cleanup round, so by default it leaves the phantoms a clone produces in place, as fossil(1)'s clone does. The follow-up pull that fetched them is now opt-in through CloneOpts.PullPhantoms, whose doc comment says enabling it departs from Section 8.2.
Both cases leave the sync deviations map. Tracked in #424.
* fix(sync): quote a client clone_seqno as received in bad command; keep DST coverage of the opt-in pull
Tracked in #424. (user: [email protected], size: 4425) [annotate] [blame] [check-ins using]
package dst import ( "context" "fmt" "math/rand" "os" "path/filepath" "testing" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/repo" libsync "github.com/danmestas/go-libfossil/internal/sync" "github.com/danmestas/go-libfossil/simio" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // runCloneDST exercises Clone under deterministic fault injection. // // 1. Create server repo, seed with 50 blobs (deterministic content from seed). // 2. Create MockFossil with server-side SeededBuggify (seed+1000). // 3. Call sync.Clone() with client-side SeededBuggify (seed+2000). // 4. On success: verify blob convergence; phantoms are expected by default // and must be gone when PullPhantoms succeeded. // 5. On failure: verify repo file deleted (error cleanup). // // Odd seeds set PullPhantoms so the opt-in follow-up pull and its stall check // keep fault-injection coverage. func runCloneDST(t *testing.T, seed int64) { t.Helper() // 1. Create and seed the server repo. serverPath := filepath.Join(t.TempDir(), "server.fossil") serverRepo, err := repo.Create(serverPath, "server", simio.CryptoRand{}, testPC) if err != nil { t.Fatalf("seed %d: repo.Create server: %v", seed, err) } t.Cleanup(func() { serverRepo.Close() }) rng := rand.New(rand.NewSource(seed)) seededUUIDs := make([]string, 0, 50) for i := range 50 { size := 64 + rng.Intn(512) data := make([]byte, size) rng.Read(data) var uuid string err := serverRepo.WithTx(func(tx *db.Tx) error { _, u, err := blob.Store(tx, data) if err != nil { return err } uuid = u return nil }) if err != nil { t.Fatalf("seed %d: store blob %d: %v", seed, i, err) } seededUUIDs = append(seededUUIDs, uuid) } // 2. Wire up MockFossil with server-side buggify. mf := NewMockFossil(serverRepo) serverBuggify := &SeededBuggify{rng: rand.New(rand.NewSource(seed + 1000))} mf.SetBuggify(serverBuggify) // 3. Clone with client-side buggify. clonePath := filepath.Join(t.TempDir(), "clone.fossil") clientBuggify := &SeededBuggify{rng: rand.New(rand.NewSource(seed + 2000))} ctx := context.Background() cloneRepo, result, cloneErr := libsync.Clone(ctx, clonePath, mf, libsync.CloneOpts{ Buggify: clientBuggify, PullPhantoms: seed%2 == 1, }) if cloneErr != nil { // 5. On failure: verify repo file was cleaned up. t.Logf("seed %d: clone failed (expected under buggify): %v", seed, cloneErr) if _, statErr := os.Stat(clonePath); !os.IsNotExist(statErr) { t.Errorf("seed %d: clone file should be deleted on error, stat returned: %v", seed, statErr) } return } // 4. On success: verify convergence. t.Cleanup(func() { cloneRepo.Close() }) t.Logf("seed %d: clone OK — %d rounds, %d blobs received", seed, result.Rounds, result.BlobsRecvd) // Verify blob convergence. Under buggify, dropFile can silently // discard received blobs, so a "successful" clone may have gaps. // We log missing blobs and only fail if zero blobs arrived. missing := 0 for _, uuid := range seededUUIDs { _, exists := blob.Exists(cloneRepo.DB(), uuid) if !exists { missing++ } } if missing > 0 { t.Logf("seed %d: %d/%d blobs missing (expected under buggify dropFile)", seed, missing, len(seededUUIDs)) } if result.BlobsRecvd == 0 { t.Errorf("seed %d: clone reported success but received 0 blobs", seed) } // Check phantom table — under buggify, residual phantoms are possible // when dropFile discards a blob that the protocol considers delivered. var phantomCount int row := cloneRepo.DB().QueryRow("SELECT COUNT(*) FROM phantom") if err := row.Scan(&phantomCount); err != nil { t.Fatalf("seed %d: query phantom count: %v", seed, err) } if seed%2 == 1 && phantomCount > 0 { t.Errorf("seed %d: PullPhantoms clone succeeded with %d phantoms", seed, phantomCount) } if phantomCount > 0 { t.Logf("seed %d: %d residual phantoms (expected under buggify)", seed, phantomCount) } } // TestCloneDST runs a single deterministic clone with fault injection. func TestCloneDST(t *testing.T) { runCloneDST(t, 42) } // TestCloneDSTSeedSweep runs 20 seeds to explore diverse fault combinations. func TestCloneDSTSeedSweep(t *testing.T) { for seed := int64(0); seed < 20; seed++ { t.Run(fmt.Sprintf("seed_%d", seed), func(t *testing.T) { runCloneDST(t, seed) }) } }