Artifact 6e4e479f50dfb58d2ea2bec27501d8dd5caed63762641de2339d73b6f5e59952:
- File
internal/sync/cluster_integration_test.go
— part of check-in
[3f290b159f]
at
2026-04-26 02:57:51
on branch trunk
— v0.4.1: xfer encoder, multi-round sync, manifest crosslink (#4)
* xfer: handle empty ServerCode/ProjectCode without arg-count error
Repo.Sync(ctx, transport, SyncOpts{Push: true, Pull: true}) with both codes empty previously wrote "push \n" to the wire. The decoder's strings.Fields collapsed adjacent spaces and failed with "xfer: pull requires 1-2 args, got 0".
Encoder now skips trailing empty args. Decoder accepts the verb with 0 args as "no specific code requested" — the same semantic as the existing 1- and 2-arg forms after fossil's normal arg interpretation.
Also fixes session.cardSummary's slice-out-of-range panic on empty codes by clamping the prefix length.
Adds a roundtrip test for the empty-codes path.
* sync: continue round-loop while either side has pending work
Sync's round-loop previously exited when the user passed Pull=false even if the peer was still requesting gimmes. The user's Pull flag controls whether we *request* new content; it should not gate whether we *service* the peer's gimmes. Symptom: agent-infra's Manager.Push had to pass Pull=true as a workaround to drive multi-round transfers.
Root cause: HandleSync.handleIGot gated gimme generation on pullOK alone. Push-only clients send igot cards from sendUnclustered every round (they always announce what they have), but the server would silently drop them because pullOK was false — leaving the server holding only what the client pushed proactively.
Continue the loop while any of: pending ungots to push, pending pulls to request, gimmes to service. Exit only when a round completes with no work in either direction. Concretely the gate is now (pushOK || pullOK), restoring parity with fossil-scm's client_sync continuation logic.
Adds an integration test (TestPushOnly_DrainsServerGimmes) that exercises the push-only path end-to-end and a unit test that asserts gimme emission under push-only handleIGot.
* sync: crosslink incoming manifests so hub-side state stays consistent
Repo.HandleSync previously stored manifest blobs in 'blob' but did not populate the relational tables (event, leaf, plink, mlink) that the timeline, fork detection, and clone protocol depend on. As a result:
- Leaves pulling from a hub got blobs but stale 'leaf' state, so WouldFork reported phantom siblings. - Verifier-clone of a hub repo failed with "sync.Clone: exceeded 100 rounds" because 'mlink' was empty and the clone protocol couldn't traverse manifest parents. - Hub timelines were always empty even after successful Push.
HandleSync now invokes manifest.Crosslink (the same scanner Clone and Sync run after convergence) when at least one file landed in the round. The scanner walks blobs that have no event/tagxref/ forumpost/attachment row yet, parses them, and populates the relational tables. Local commits already produced this state via manifest.Checkin's existing crosslink path; incoming syncs now match.
Coverage spans every artifact type the existing scanner already supports (ci, wiki, ticket, event, attachment, cluster, forum, control). The agent-infra trial that surfaced this only exercises 'ci' artifacts, so the divergence note in the prompt described the narrowest possible repair; reusing the existing scanner avoids the divergence and matches the C reference's full coverage.
Adds two tests: TestHandleSync_CrosslinksReceivedManifest verifies event/leaf/mlink rows after pushing a single checkin; the leaf- transition test verifies the parent leaves the leaf table when the child lands and that plink connects them. (user: [email protected], size: 5424) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "path/filepath" "testing" "github.com/danmestas/libfossil/internal/blob" "github.com/danmestas/libfossil/internal/repo" "github.com/danmestas/libfossil/simio" "github.com/danmestas/libfossil/internal/xfer" _ "github.com/danmestas/libfossil/internal/testdriver" ) // TestPushOnly_DrainsServerGimmes verifies that when the client pushes // without pulling (SyncOpts{Push:true, Pull:false}), the server still // requests blobs it does not have via gimme cards and the client services // them across multiple rounds. // // Regression: in v0.4.0 the server only emitted gimme cards when pullOK // was true (driven by the client's pull card). Push-only clients exited // after one round even when the server was missing artifacts, leaving // the server with an incomplete blob store. func TestPushOnly_DrainsServerGimmes(t *testing.T) { clientPath := filepath.Join(t.TempDir(), "client.fossil") clientRepo, err := repo.Create(clientPath, "testuser", simio.CryptoRand{}) if err != nil { t.Fatalf("repo.Create client: %v", err) } t.Cleanup(func() { clientRepo.Close() }) const numBlobs = 50 for i := range numBlobs { content := []byte(fmt.Sprintf("push-only-blob-%04d", i)) if _, _, err := blob.Store(clientRepo.DB(), content); err != nil { t.Fatalf("blob.Store[%d]: %v", i, err) } } serverPath := filepath.Join(t.TempDir(), "server.fossil") serverRepo, err := repo.Create(serverPath, "testuser", simio.CryptoRand{}) if err != nil { t.Fatalf("repo.Create server: %v", err) } t.Cleanup(func() { serverRepo.Close() }) transport := &MockTransport{ Handler: func(req *xfer.Message) *xfer.Message { resp, err := HandleSync(context.Background(), serverRepo, req) if err != nil { t.Fatalf("HandleSync: %v", err) } return resp }, } var projCode, srvCode string clientRepo.DB().QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&projCode) clientRepo.DB().QueryRow("SELECT value FROM config WHERE name='server-code'").Scan(&srvCode) result, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Push: true, Pull: false, ProjectCode: projCode, ServerCode: srvCode, }) if err != nil { t.Fatalf("Sync: %v", err) } var serverCount int if err := serverRepo.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&serverCount); err != nil { t.Fatalf("count blobs: %v", err) } t.Logf("push-only sync completed: rounds=%d sent=%d server_blobs=%d", result.Rounds, result.FilesSent, serverCount) if serverCount < numBlobs { t.Fatalf("server has %d blobs, want >= %d (push-only did not drain server gimmes)", serverCount, numBlobs) } if result.FilesSent < numBlobs { t.Fatalf("FilesSent = %d, want >= %d (multi-round push-only failed to send all)", result.FilesSent, numBlobs) } // Multi-round behaviour: the protocol needs at least 2 rounds (igot then file). if result.Rounds < 2 { t.Fatalf("Rounds = %d, want >= 2 (push-only converged before draining gimmes)", result.Rounds) } } // TestClusterSync_RoundTrip creates a client repo with 200 blobs, syncs them // to an empty server repo via MockTransport wrapping HandleSync, and verifies // all 200 blobs arrive within 10 rounds. func TestClusterSync_RoundTrip(t *testing.T) { // --- Setup: client repo with 200 blobs --- clientPath := filepath.Join(t.TempDir(), "client.fossil") clientRepo, err := repo.Create(clientPath, "testuser", simio.CryptoRand{}) if err != nil { t.Fatalf("repo.Create client: %v", err) } t.Cleanup(func() { clientRepo.Close() }) for i := range 200 { content := []byte(fmt.Sprintf("cluster-roundtrip-blob-%04d", i)) _, _, err := blob.Store(clientRepo.DB(), content) if err != nil { t.Fatalf("blob.Store[%d]: %v", i, err) } } // --- Setup: empty server repo --- serverPath := filepath.Join(t.TempDir(), "server.fossil") serverRepo, err := repo.Create(serverPath, "testuser", simio.CryptoRand{}) if err != nil { t.Fatalf("repo.Create server: %v", err) } t.Cleanup(func() { serverRepo.Close() }) // --- Transport: MockTransport wrapping HandleSync --- transport := &MockTransport{ Handler: func(req *xfer.Message) *xfer.Message { resp, err := HandleSync(context.Background(), serverRepo, req) if err != nil { t.Fatalf("HandleSync: %v", err) } return resp }, } // --- Sync: client pushes to server --- var projCode, srvCode string clientRepo.DB().QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&projCode) clientRepo.DB().QueryRow("SELECT value FROM config WHERE name='server-code'").Scan(&srvCode) result, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Push: true, Pull: true, ProjectCode: projCode, ServerCode: srvCode, }) if err != nil { t.Fatalf("Sync: %v", err) } // --- Verify: all 200 blobs arrived --- var serverCount int err = serverRepo.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&serverCount) if err != nil { t.Fatalf("count blobs: %v", err) } t.Logf("sync completed: rounds=%d sent=%d recv=%d server_blobs=%d", result.Rounds, result.FilesSent, result.FilesRecvd, serverCount) if serverCount < 200 { t.Fatalf("server has %d blobs, want >= 200", serverCount) } // --- Verify: converged within 10 rounds --- if result.Rounds > 10 { t.Fatalf("took %d rounds, want <= 10", result.Rounds) } }