Artifact c5403dd8a2f1b872756993416321c3be7859549cc937b137612b4049a834c5fb:
- File
internal/sync/handler_crosslink_test.go
— part of check-in
[c5f5838169]
at
2026-10-13 14:09:47
on branch main
— fix(sync): run each request, reply and clone round in one transaction (#422)
A received delta found wrong when its basis arrives later in the pass fails sync-protocol-01 Section 7.7's commit check, which now rolls back everything the pass stored, as fossil's page_xfer and client_sync transactions do, instead of undoing the delta by hand and keeping the rest. The phantom-fill cascades and the server's closing leaf and tag repair run inside that transaction. Any other card error still commits the prefix before it (Section 10.1); on the client that prefix now includes the reply's buffered igot phantoms and visibility changes.
db.DB.WithBoundTx binds a DB to one write transaction so code written against *DB, nested WithTx included, joins it as savepoints instead of contending for the write lock from a second connection.
SP-7.7-4 is covered; the new SP-10.1-6 has sync.10.1.integrity-error-prefix. Tracked in #418. (user: [email protected], size: 8465) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/xfer" ) // buildCheckinManifest constructs a checkin manifest referencing the given // file blob and returns the wire bytes plus the manifest UUID. Parents may // be empty for a root-of-tree checkin. func buildCheckinManifest(t *testing.T, comment string, when time.Time, fileUUID string, fileContent []byte, parents []string) ([]byte, string) { t.Helper() d := &deck.Deck{ Type: deck.Checkin, C: comment, D: when, F: []deck.FileCard{{Name: "hello.txt", UUID: fileUUID}}, U: deck.User("tester"), P: parents, T: []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk"}, {Type: deck.TagSingleton, Name: "sym-trunk", UUID: "*"}, }, } rHash, err := d.ComputeR(func(uuid string) ([]byte, error) { if uuid == fileUUID { return fileContent, nil } return nil, fmt.Errorf("unknown uuid: %s", uuid) }) if err != nil { t.Fatalf("ComputeR: %v", err) } d.R = rHash manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } return manifestBytes, hash.SHA1(manifestBytes) } // TestHandleSync_CrosslinksReceivedManifest verifies that a manifest pushed // to the server via HandleSync is crosslinked into event/leaf/plink/mlink. // // Pre-fix, HandleSync stored the blob durably but did not call the // crosslink scanner, so the hub's timeline, fork detection, and clone // protocol all observed empty relational state. func TestHandleSync_CrosslinksReceivedManifest(t *testing.T) { r := setupPushableTestRepo(t) fileContent := []byte("hello from crosslink test") fileUUID := hash.SHA1(fileContent) manifestBytes, manifestUUID := buildCheckinManifest(t, "first checkin", time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC), fileUUID, fileContent, nil) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: testPC}, &xfer.FileCard{UUID: fileUUID, Content: fileContent}, &xfer.FileCard{UUID: manifestUUID, Content: manifestBytes}, }} if _, err := HandleSync(context.Background(), r, req); err != nil { t.Fatalf("HandleSync: %v", err) } manifestRID, ok := blob.Exists(r.DB(), manifestUUID) if !ok { t.Fatal("manifest blob not stored") } // event table: one row of type='ci' for the checkin. var eventCount int if err := r.DB().QueryRow( "SELECT count(*) FROM event WHERE type='ci' AND objid=?", manifestRID, ).Scan(&eventCount); err != nil { t.Fatalf("count event: %v", err) } if eventCount != 1 { t.Fatalf("event ci rows for objid=%d: got %d, want 1", manifestRID, eventCount) } // leaf table: the checkin is a tip. var leafCount int if err := r.DB().QueryRow( "SELECT count(*) FROM leaf WHERE rid=?", manifestRID, ).Scan(&leafCount); err != nil { t.Fatalf("count leaf: %v", err) } if leafCount != 1 { t.Fatalf("leaf rows for rid=%d: got %d, want 1", manifestRID, leafCount) } // mlink table: one row mapping the manifest to its file. var mlinkCount int if err := r.DB().QueryRow( "SELECT count(*) FROM mlink WHERE mid=?", manifestRID, ).Scan(&mlinkCount); err != nil { t.Fatalf("count mlink: %v", err) } if mlinkCount != 1 { t.Fatalf("mlink rows for mid=%d: got %d, want 1", manifestRID, mlinkCount) } } // TestHandleSync_LeafTransitionOnChildCheckin verifies that when a child // checkin lands via HandleSync, the parent is removed from leaf and the // child becomes the new tip. This is the operation that produces phantom // "WouldFork" reports in v0.4.0 when crosslink is missing. func TestHandleSync_LeafTransitionOnChildCheckin(t *testing.T) { r := setupPushableTestRepo(t) parentFileContent := []byte("parent file content") parentFileUUID := hash.SHA1(parentFileContent) parentBytes, parentUUID := buildCheckinManifest(t, "parent", time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC), parentFileUUID, parentFileContent, nil) childFileContent := []byte("child file content") childFileUUID := hash.SHA1(childFileContent) childBytes, childUUID := buildCheckinManifest(t, "child", time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC), childFileUUID, childFileContent, []string{parentUUID}) // Push the parent first. if _, err := HandleSync(context.Background(), r, &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: testPC}, &xfer.FileCard{UUID: parentFileUUID, Content: parentFileContent}, &xfer.FileCard{UUID: parentUUID, Content: parentBytes}, }}); err != nil { t.Fatalf("HandleSync(parent): %v", err) } parentRID, _ := blob.Exists(r.DB(), parentUUID) // Confirm parent is a leaf. var leafBefore int r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", parentRID).Scan(&leafBefore) if leafBefore != 1 { t.Fatalf("after parent push: leaf rows for parent=%d got %d, want 1", parentRID, leafBefore) } // Push the child. if _, err := HandleSync(context.Background(), r, &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: testPC}, &xfer.FileCard{UUID: childFileUUID, Content: childFileContent}, &xfer.FileCard{UUID: childUUID, Content: childBytes}, }}); err != nil { t.Fatalf("HandleSync(child): %v", err) } childRID, _ := blob.Exists(r.DB(), childUUID) // Parent must be gone from leaf, child must replace it. var parentLeaf, childLeaf int r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", parentRID).Scan(&parentLeaf) r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", childRID).Scan(&childLeaf) if parentLeaf != 0 { t.Errorf("parent leaf count = %d, want 0 (child should have superseded)", parentLeaf) } if childLeaf != 1 { t.Errorf("child leaf count = %d, want 1", childLeaf) } // plink table: one row connecting parent to child. var plinkCount int r.DB().QueryRow( "SELECT count(*) FROM plink WHERE pid=? AND cid=?", parentRID, childRID, ).Scan(&plinkCount) if plinkCount != 1 { t.Errorf("plink rows pid=%d cid=%d: got %d, want 1", parentRID, childRID, plinkCount) } } // TestHandleLateMismatchRollsBackRequest: a request whose late hash mismatch // fails the commit check (ยง7.7, SP-7.7-4) is rolled back whole, as fossil's // page_xfer transaction is: the phantom an earlier card filled is a phantom // again and the crosslinks its cascade wrote are gone. The reply is the // mismatch's error and the closing timestamp. func TestHandleLateMismatchRollsBackRequest(t *testing.T) { // Control: without the bad delta, the fill crosslinks every child. ctl := setupPushableTestRepo(t) ctlUUID, ctlBase, ctlChildren := buildPhantomFillFanout(t, ctl, 3) if _, err := HandleSync(context.Background(), ctl, &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: testPC}, &xfer.FileCard{UUID: ctlUUID, Content: ctlBase}, }}); err != nil { t.Fatalf("HandleSync control: %v", err) } if got := countCrosslinkedRids(t, ctl, ctlChildren); got != len(ctlChildren) { t.Fatalf("control crosslinked %d of %d children", got, len(ctlChildren)) } r := setupPushableTestRepo(t) baseUUID, base, children := buildPhantomFillFanout(t, r, 3) fx := newLateBasisFixture(t) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: testPC}, &xfer.FileCard{UUID: baseUUID, Content: base}, &xfer.FileCard{UUID: fx.claimed, DeltaSrc: fx.baseUUID, Content: fx.evil}, &xfer.FileCard{UUID: fx.baseUUID, Content: []byte(fx.base)}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } if n := len(resp.Cards); n != 2 { t.Fatalf("reply has %d cards, want the error and the timestamp", n) } e, ok := resp.Cards[0].(*xfer.ErrorCard) if !ok || e.Message != wrongHashText(fx.claimed) { t.Fatalf("reply starts with %v, want the wrong-hash error", resp.Cards[0]) } if all, phantoms := rows(t, r, baseUUID); all != 1 || phantoms != 1 { t.Fatalf("filled phantom rows = %d (%d phantom), want the phantom back", all, phantoms) } for _, uuid := range []string{fx.claimed, fx.baseUUID} { if all, _ := rows(t, r, uuid); all != 0 { t.Fatalf("%d rows for %s, want 0", all, uuid) } } if got := countCrosslinkedRids(t, r, children); got != 0 { t.Fatalf("%d children crosslinked by a rolled-back request, want 0", got) } }