Artifact 1063bbbee0b4d6cdc849c181281aa0f03695ddcb73a78d5fc6a45a66b8d867de:
- File
conformance/horizon_receive_test.go
— part of check-in
[41c092205e]
at
2026-10-28 12:52:58
on branch fix/seed-delta-drop
— seedcheck: keep deltas on a dropped seed readable; make a drop atomic
Review follow-ups to the seed drop.
A delta that arrives on a seed already dropped could never be read: it was stored on a content-less row that is never filled or asked for. It is now not stored as a delta but left a phantom and asked for again in full, on a pull, a clone's phantom pull and a push to an origin. A seed that arrives in full after deltas built on it is stored just long enough to check those deltas, judge them and rewrite them as full text, and then dropped.
seedcheck.Drop runs in a savepoint (or a transaction of its own on a pool), so a failure partway, which the after-dephantomize cascade logs and commits past, leaves the seed and its dependents as they were.
An origin answering a push no longer asks for a refused seed's phantom, and a clone asking for what it left missing no longer counts one. fossil 2.28's rebuild re-adds every size<0 row to the phantom table and deletes the store-local seedrefused and seedpending tables; the CHANGELOG notes this and the one-time drop of refused seeds that older stores kept.
The client now adds to a reply's new-phantom count rather than replacing it, so a phantom made while receiving a file continues the exchange. (user: [email protected], size: 22288) [annotate] [blame] [check-ins using] [more...]
package conformance import ( "context" "path/filepath" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/delta" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/horizon/seed" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/seedcheck" "github.com/danmestas/go-libfossil/internal/sync" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/simio" ) // The receive rule (ยง5.2.3): a store never keeps a seed it did not receive // through a horizon-seed announcement. On every receive path it drops an // artifact that is a seed by its own label or by a binding card, stores one // that carries neither as ordinary, does not count or ask again for what it // dropped, keeps a record of the dropped hashes across sessions, and never // announces or serves a delta it cannot yet expand. func horizonReceiveCases() []Case { return []Case{horizonReceivePull, horizonReceiveClone, horizonReceiveLate, horizonReceiveLatePull, horizonReceiveLateOrigin, horizonReceiveOnDroppedPull, horizonReceiveOnDroppedClone, horizonReceiveOnDroppedOrigin, horizonReceiveSeedBasisPull, horizonReceiveSeedBasisClone, horizonReceiveRecord, horizonReceiveWithheld} } var ( horizonReceivePull = Case{ID: "horizon.5.2.3.receive-drop-pull", Req: "HZ-5.2-14"} horizonReceiveClone = Case{ID: "horizon.5.2.3.receive-drop-clone", Req: "HZ-5.2-14"} horizonReceiveLate = Case{ID: "horizon.5.2.3.receive-late-delta-unlinked", Req: "HZ-5.2-14"} horizonReceiveLatePull = Case{ID: "horizon.5.2.3.receive-late-delta-pull-dropped", Req: "HZ-5.2-14"} horizonReceiveLateOrigin = Case{ID: "horizon.5.2.3.receive-late-delta-origin-dropped", Req: "HZ-5.2-14"} horizonReceiveOnDroppedPull = Case{ID: "horizon.5.2.3.receive-delta-on-dropped-seed-pull", Req: "HZ-5.2-14"} horizonReceiveOnDroppedClone = Case{ID: "horizon.5.2.3.receive-delta-on-dropped-seed-clone", Req: "HZ-5.2-14"} horizonReceiveOnDroppedOrigin = Case{ID: "horizon.5.2.3.receive-delta-on-dropped-seed-origin", Req: "HZ-5.2-14"} horizonReceiveSeedBasisPull = Case{ID: "horizon.5.2.3.receive-seed-basis-after-delta-pull", Req: "HZ-5.2-14"} horizonReceiveSeedBasisClone = Case{ID: "horizon.5.2.3.receive-seed-basis-after-delta-clone", Req: "HZ-5.2-14"} horizonReceiveRecord = Case{ID: "horizon.5.2.3.receive-dropped-not-asked-again", Req: "HZ-5.2-15"} horizonReceiveWithheld = Case{ID: "horizon.5.2.3.receive-dangling-withheld", Req: "HZ-5.2-16"} ) const hzReceivePC = "0123456789abcdef0123456789abcdef01234567" // hzReceiveRepo is a new store under the suite's project code. func hzReceiveRepo(t *testing.T, name string) *repo.Repo { t.Helper() r, err := repo.Create(filepath.Join(t.TempDir(), name+".fossil"), "u", simio.CryptoRand{}, hzReceivePC) if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close() }) return r } // hzControl is a control artifact by user tagging target, carrying a // horizon-seed binding card when bound. func hzControl(t *testing.T, user, tagName, target string, bound bool) []byte { t.Helper() cards := []deck.TagCard{{Type: deck.TagSingleton, Name: tagName, UUID: target}} if bound { cards = append(cards, deck.TagCard{Type: deck.TagSingleton, Name: seed.BindingTag, UUID: target, Value: "descendants"}) } data, err := (&deck.Deck{Type: deck.Control, D: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC), U: deck.User(user), T: cards}).Marshal() if err != nil { t.Fatal(err) } return data } // hzRecording serves upstream and records every request. type hzRecording struct { upstream *repo.Repo requests []*xfer.Message } func (p *hzRecording) Exchange(ctx context.Context, req *xfer.Message) (*xfer.Message, error) { p.requests = append(p.requests, req) return sync.HandleSync(ctx, p.upstream, req) } func (p *hzRecording) asked(uuid string) bool { for _, req := range p.requests { for _, c := range req.Cards { if g, ok := c.(*xfer.GimmeCard); ok && g.UUID == uuid { return true } } } return false } // hzHeldReal reports whether r holds uuid's content. func hzHeldReal(r *repo.Repo, uuid string) bool { var n int r.DB().QueryRow("SELECT count(*) FROM blob WHERE uuid=? AND size>=0", uuid).Scan(&n) return n > 0 } // hzLinked reports whether uuid's tags reached r's derived state. func hzLinked(r *repo.Repo, uuid string) bool { var n int r.DB().QueryRow("SELECT count(*) FROM tagxref JOIN blob ON blob.rid=tagxref.srcid WHERE blob.uuid=?", uuid).Scan(&n) return n > 0 } func TestHorizonReceive(t *testing.T) { check(t, horizonReceivePull, func(t *testing.T) error { return hzReceivePull(t) }) check(t, horizonReceiveClone, func(t *testing.T) error { return hzReceiveClone(t) }) check(t, horizonReceiveLate, func(t *testing.T) error { return hzReceiveLate(t) }) check(t, horizonReceiveLatePull, func(t *testing.T) error { return hzReceiveLatePull(t) }) check(t, horizonReceiveLateOrigin, func(t *testing.T) error { return hzReceiveLateOrigin(t) }) check(t, horizonReceiveOnDroppedPull, func(t *testing.T) error { return hzReceiveOnDropped(t, false) }) check(t, horizonReceiveOnDroppedClone, func(t *testing.T) error { return hzReceiveOnDropped(t, true) }) check(t, horizonReceiveOnDroppedOrigin, func(t *testing.T) error { return hzReceiveOnDroppedOrigin(t) }) check(t, horizonReceiveSeedBasisPull, func(t *testing.T) error { return hzReceiveSeedBasis(t, false) }) check(t, horizonReceiveSeedBasisClone, func(t *testing.T) error { return hzReceiveSeedBasis(t, true) }) check(t, horizonReceiveRecord, func(t *testing.T) error { return hzReceiveRecord(t) }) check(t, horizonReceiveWithheld, func(t *testing.T) error { return hzReceiveWithheld(t) }) } // hzReceiveUpstream is a peer holding, stored raw as a peer predating the // rule might, a seed by this store's label, a seed by a binding card under // zit's label, and a control artifact under zit's label with neither. func hzReceiveUpstream(t *testing.T) (*repo.Repo, []string, string) { t.Helper() up := hzReceiveRepo(t, "upstream") target := hash.SHA1([]byte("a target\n")) var seeds []string for _, data := range [][]byte{ hzControl(t, seedcheck.Label, "alpha", target, false), hzControl(t, "zit-seed", "beta", target, true), } { _, uuid, err := blob.Store(up.DB(), data) if err != nil { t.Fatal(err) } seeds = append(seeds, uuid) } _, ordinary, err := blob.Store(up.DB(), hzControl(t, "zit-seed", "gamma", target, false)) if err != nil { t.Fatal(err) } return up, seeds, ordinary } // hzReceivePull: a pull drops both seeds, counting neither, and stores the // foreign artifact that carries neither label nor card as ordinary. func hzReceivePull(t *testing.T) error { up, seeds, ordinary := hzReceiveUpstream(t) client := hzReceiveRepo(t, "client") peer := &hzRecording{upstream: up} res, err := sync.Sync(context.Background(), client, peer, sync.SyncOpts{Pull: true}) if err != nil { return err } for _, s := range seeds { if !peer.asked(s) { return expectf("fixture: the client never asked for %.10s", s) } if hzHeldReal(client, s) { return expectf("the pulled seed %.10s was stored", s) } } if !hzHeldReal(client, ordinary) || !hzLinked(client, ordinary) { return expectf("the foreign artifact carrying neither label nor card is not an ordinary control artifact") } var held int client.DB().QueryRow("SELECT count(*) FROM blob WHERE size>=0").Scan(&held) if res.FilesRecvd != held { return expectf("%d files counted received, %d held", res.FilesRecvd, held) } return nil } // hzReceiveClone: a clone drops a seed it receives, counting it not. func hzReceiveClone(t *testing.T) error { data := hzControl(t, seedcheck.Label, "alpha", hash.SHA1([]byte("a target\n")), false) ordinary := []byte("an ordinary file\n") tr := hzCloneScript( []xfer.Card{&xfer.PushCard{ServerCode: "s", ProjectCode: hzReceivePC}, &xfer.FileCard{UUID: hash.SHA1(data), Content: data}, &xfer.FileCard{UUID: hash.SHA1(ordinary), Content: ordinary}, &xfer.CloneSeqNoCard{SeqNo: 0}}, []xfer.Card{&xfer.CloneSeqNoCard{SeqNo: 0}}, ) clone, res, err := sync.Clone(context.Background(), filepath.Join(t.TempDir(), "c.fossil"), tr, sync.CloneOpts{}) if err != nil { return err } defer clone.Close() if hzHeldReal(clone, hash.SHA1(data)) { return expectf("the cloned seed was stored") } if res.BlobsRecvd != 1 { return expectf("%d blobs counted received, want the ordinary file alone", res.BlobsRecvd) } return nil } // hzLateSeed is this store's own seed sent as a delta on base, with a // delta built on the seed: the cards that carry them, and the dependent's // bytes. type hzLateSeed struct { seed, dependent *xfer.FileCard dependentData []byte } func newHZLateSeed(t *testing.T, base []byte) (hzLateSeed, error) { t.Helper() data := hzControl(t, seedcheck.Label, "alpha", hash.SHA1([]byte("a target\n")), false) d, err := delta.Create(base, data) if err != nil { return hzLateSeed{}, err } dep := append([]byte("an ordinary file built on the seed\n"), data...) dd, err := delta.Create(data, dep) if err != nil { return hzLateSeed{}, err } return hzLateSeed{ seed: &xfer.FileCard{UUID: hash.SHA1(data), DeltaSrc: hash.SHA1(base), Content: d}, dependent: &xfer.FileCard{UUID: hash.SHA1(dep), DeltaSrc: hash.SHA1(data), Content: dd}, dependentData: dep, }, nil } // dropped checks r keeps no content for the seed, refuses it, and still // reads the delta built on it. func (l hzLateSeed) dropped(r *repo.Repo) error { if hzHeldReal(r, l.seed.UUID) { return expectf("the seed completed in a later round keeps a content row") } if hzLinked(r, l.seed.UUID) { return expectf("the seed completed in a later round was linked") } if refused, err := seedcheck.IsRefusedUUID(r.DB(), l.seed.UUID); err != nil || !refused { return expectf("the seed completed in a later round is not refused (%v)", err) } rid, ok := blob.Exists(r.DB(), l.dependent.UUID) if !ok { return expectf("the delta built on the seed was not stored") } got, err := content.Expand(r.DB(), rid) if err != nil || string(got) != string(l.dependentData) { return expectf("the delta built on the dropped seed no longer expands (%v)", err) } return nil } // hzHeldCount is how many artifacts r holds content for. func hzHeldCount(r *repo.Repo) int { var held int r.DB().QueryRow("SELECT count(*) FROM blob WHERE size>=0").Scan(&held) return held } // hzReceiveLate: a seed a clone receives as a delta whose base comes in a // later round is dropped once the base makes it readable, is not counted, // and the delta built on it still expands. func hzReceiveLate(t *testing.T) error { base := []byte("a base sent in the next round\n") l, err := newHZLateSeed(t, base) if err != nil { return err } tr := hzCloneScript( []xfer.Card{&xfer.PushCard{ServerCode: "s", ProjectCode: hzReceivePC}, l.seed, l.dependent, &xfer.CloneSeqNoCard{SeqNo: 1}}, []xfer.Card{&xfer.FileCard{UUID: hash.SHA1(base), Content: base}, &xfer.CloneSeqNoCard{SeqNo: 0}}, []xfer.Card{&xfer.CloneSeqNoCard{SeqNo: 0}}, ) clone, res, err := sync.Clone(context.Background(), filepath.Join(t.TempDir(), "c.fossil"), tr, sync.CloneOpts{}) if err != nil { return err } defer clone.Close() if err := l.dropped(clone); err != nil { return err } if held := hzHeldCount(clone); res.BlobsRecvd != held { return expectf("%d blobs counted received, %d held", res.BlobsRecvd, held) } return nil } // hzReceiveLatePull: the same on a pull, whose base arrives in a later // reply: the seed is dropped, not counted, and not asked for again. func hzReceiveLatePull(t *testing.T) error { base := []byte("a base the next reply sends\n") l, err := newHZLateSeed(t, base) if err != nil { return err } replies := [][]xfer.Card{{l.seed, l.dependent}, {&xfer.FileCard{UUID: hash.SHA1(base), Content: base}}} var asked []*xfer.Message tr := &sync.MockTransport{Handler: func(req *xfer.Message) *xfer.Message { asked = append(asked, req) if len(asked) > len(replies) { return &xfer.Message{} } return &xfer.Message{Cards: replies[len(asked)-1]} }} client := hzReceiveRepo(t, "client") res, err := sync.Sync(context.Background(), client, tr, sync.SyncOpts{Pull: true, ProjectCode: hzReceivePC}) if err != nil { return err } if err := l.dropped(client); err != nil { return err } if held := hzHeldCount(client); res.FilesRecvd != held { return expectf("%d files counted received, %d held", res.FilesRecvd, held) } for _, req := range asked[min(2, len(asked)):] { for _, c := range req.Cards { if g, ok := c.(*xfer.GimmeCard); ok && g.UUID == l.seed.UUID { return expectf("the dropped seed was asked for again") } } } return nil } // hzReceiveOnDroppedOrigin: an origin pushed a delta on a seed it already // dropped does not keep it as one, asks the pusher for it, and keeps the // full text that answer brings. func hzReceiveOnDroppedOrigin(t *testing.T) error { base := []byte("a base the next request pushes\n") l, err := newHZLateSeed(t, base) if err != nil { return err } origin := hzReceiveRepo(t, "origin") if _, err := origin.DB().Exec("UPDATE user SET cap='gio' WHERE login='nobody'"); err != nil { return err } push := func(cards ...xfer.Card) (*xfer.Message, error) { req := &xfer.Message{Cards: append([]xfer.Card{&xfer.PushCard{ServerCode: "s", ProjectCode: hzReceivePC}}, cards...)} resp, err := sync.HandleSync(context.Background(), origin, req) if err == nil && len(hzErrors(resp)) != 0 { err = expectf("push: errors %q", hzErrors(resp)) } return resp, err } for _, cards := range [][]xfer.Card{{l.seed}, {&xfer.FileCard{UUID: hash.SHA1(base), Content: base}}} { if _, err := push(cards...); err != nil { return err } } resp, err := push(l.dependent) if err != nil { return err } asked := false for _, c := range resp.Cards { if g, ok := c.(*xfer.GimmeCard); ok && g.UUID == l.dependent.UUID { asked = true } } if !asked { return expectf("the origin did not ask for the delta on the dropped seed in full") } if _, err := push(&xfer.FileCard{UUID: l.dependent.UUID, Content: l.dependentData}); err != nil { return err } return l.dropped(origin) } // hzReceiveLateOrigin: an origin that stores a pushed seed as a delta whose // base only a later request supplies drops it then, keeps the delta built on // it readable, and does not ask the pusher for it. func hzReceiveLateOrigin(t *testing.T) error { base := []byte("a base the next request pushes\n") l, err := newHZLateSeed(t, base) if err != nil { return err } origin := hzReceiveRepo(t, "origin") // Anyone may push, so the requests carry no login. if _, err := origin.DB().Exec("UPDATE user SET cap='gio' WHERE login='nobody'"); err != nil { return err } push := func(cards ...xfer.Card) (*xfer.Message, error) { req := &xfer.Message{Cards: append([]xfer.Card{&xfer.PushCard{ServerCode: "s", ProjectCode: hzReceivePC}}, cards...)} return sync.HandleSync(context.Background(), origin, req) } r1, err := push(l.seed, l.dependent) if err != nil { return err } if errs := hzErrors(r1); len(errs) != 0 { return expectf("the dangling push: errors %q", errs) } resp, err := push(&xfer.FileCard{UUID: hash.SHA1(base), Content: base}) if err != nil { return err } if errs := hzErrors(resp); len(errs) != 0 { return expectf("the base's push: errors %q", errs) } if err := l.dropped(origin); err != nil { return err } for _, c := range resp.Cards { if g, ok := c.(*xfer.GimmeCard); ok && g.UUID == l.seed.UUID { return expectf("the origin asked the pusher for the dropped seed") } } return nil } // hzServe answers a pull or clone from fixed replies, one per request, and // a request asking for the dependent with its full text, as a peer sends a // gimme'd artifact. Requests past the last reply get an empty one. type hzServe struct { l hzLateSeed replies [][]xfer.Card asked []*xfer.Message } func (p *hzServe) Exchange(_ context.Context, req *xfer.Message) (*xfer.Message, error) { p.asked = append(p.asked, req) for _, c := range req.Cards { if g, ok := c.(*xfer.GimmeCard); ok && g.UUID == p.l.dependent.UUID { return &xfer.Message{Cards: []xfer.Card{ &xfer.FileCard{UUID: p.l.dependent.UUID, Content: p.l.dependentData}}}, nil } } if len(p.asked) > len(p.replies) { return &xfer.Message{}, nil } return &xfer.Message{Cards: p.replies[len(p.asked)-1]}, nil } // hzReceiveVia runs a clone of p's replies, asking for what it left // missing, or a pull session per batch of sessions, and checks the seed was dropped, the delta built on it reads, // and the count is what is held. func hzReceiveVia(t *testing.T, p *hzServe, clone bool, sessions ...[][]xfer.Card) error { var r *repo.Repo var recvd int if clone { c, res, err := sync.Clone(context.Background(), filepath.Join(t.TempDir(), "c.fossil"), p, sync.CloneOpts{PullPhantoms: true}) if err != nil { return err } defer c.Close() r, recvd = c, res.BlobsRecvd } else { // A pull ends once nothing it knows of is missing, so each batch of // replies is a session of its own. r = hzReceiveRepo(t, "client") for _, replies := range sessions { p.replies, p.asked = replies, nil res, err := sync.Sync(context.Background(), r, p, sync.SyncOpts{Pull: true, ProjectCode: hzReceivePC}) if err != nil { return err } recvd += res.FilesRecvd } } if err := p.l.dropped(r); err != nil { return err } if held := hzHeldCount(r); recvd != held { return expectf("%d counted received, %d held", recvd, held) } return nil } // hzReceiveOnDropped: a delta on a seed already dropped can never be read // from what is stored, so it is not kept as one: it is asked for again, // and its full text expands. func hzReceiveOnDropped(t *testing.T, clone bool) error { base := []byte("a base that completes the seed\n") l, err := newHZLateSeed(t, base) if err != nil { return err } baseCard := &xfer.FileCard{UUID: hash.SHA1(base), Content: base} p := &hzServe{l: l} if !clone { return hzReceiveVia(t, p, false, [][]xfer.Card{{l.seed}, {baseCard}}, [][]xfer.Card{{l.dependent}}) } push := &xfer.PushCard{ServerCode: "s", ProjectCode: hzReceivePC} p.replies = [][]xfer.Card{ {push, l.seed, &xfer.CloneSeqNoCard{SeqNo: 2}}, {baseCard, &xfer.CloneSeqNoCard{SeqNo: 3}}, {l.dependent, &xfer.CloneSeqNoCard{SeqNo: 0}}, } return hzReceiveVia(t, p, true) } // hzReceiveSeedBasis: a delta stored on a basis it lacks, whose basis then // arrives in full and is a seed: the seed's bytes are the only way the delta // can be read, so it is read and kept, and the seed dropped. func hzReceiveSeedBasis(t *testing.T, clone bool) error { l, err := newHZLateSeed(t, []byte("a base never sent\n")) if err != nil { return err } data := hzControl(t, seedcheck.Label, "alpha", hash.SHA1([]byte("a target\n")), false) full := &xfer.FileCard{UUID: l.seed.UUID, Content: data} l.seed = full p := &hzServe{l: l} if !clone { return hzReceiveVia(t, p, false, [][]xfer.Card{{l.dependent}, {full}}) } push := &xfer.PushCard{ServerCode: "s", ProjectCode: hzReceivePC} p.replies = [][]xfer.Card{ {push, l.dependent, &xfer.CloneSeqNoCard{SeqNo: 2}}, {full, &xfer.CloneSeqNoCard{SeqNo: 0}}, } return hzReceiveVia(t, p, true) } // hzReceiveRecord: a later session, from another peer that announces the // dropped seeds, does not ask for them again. func hzReceiveRecord(t *testing.T) error { up, seeds, _ := hzReceiveUpstream(t) client := hzReceiveRepo(t, "client") if _, err := sync.Sync(context.Background(), client, &hzRecording{upstream: up}, sync.SyncOpts{Pull: true}); err != nil { return err } other, _, _ := hzReceiveUpstream(t) peer := &hzRecording{upstream: other} if _, err := sync.Sync(context.Background(), client, peer, sync.SyncOpts{Pull: true}); err != nil { return err } for _, s := range seeds { if peer.asked(s) { return expectf("a later session asked for the dropped %.10s again", s) } } return nil } // hzReceiveWithheld: a pulled delta whose base never arrives is kept, but a // peer pulling from this store is neither announced nor sent it. func hzReceiveWithheld(t *testing.T) error { base := []byte("a base the peer never sends\n") target := []byte("an ordinary file sent as a delta on it\n") d, err := delta.Create(base, target) if err != nil { return err } card := &xfer.FileCard{UUID: hash.SHA1(target), DeltaSrc: hash.SHA1(base), Content: d} sent := false tr := &sync.MockTransport{Handler: func(*xfer.Message) *xfer.Message { if sent { return &xfer.Message{} } sent = true return &xfer.Message{Cards: []xfer.Card{card}} }} client := hzReceiveRepo(t, "client") if _, err := sync.Sync(context.Background(), client, tr, sync.SyncOpts{Pull: true, ProjectCode: hzReceivePC}); err != nil { return err } if _, ok := blob.Exists(client.DB(), card.UUID); !ok { return expectf("fixture: the dangling delta was not kept") } resp, err := sync.HandleSync(context.Background(), client, &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "s", ProjectCode: hzReceivePC}, &xfer.GimmeCard{UUID: card.UUID}}}) if err != nil { return err } for _, c := range resp.Cards { switch c := c.(type) { case *xfer.IGotCard: if c.UUID == card.UUID { return expectf("the unexpandable delta was announced") } case *xfer.FileCard: if c.UUID == card.UUID { return expectf("the unexpandable delta was sent") } case *xfer.CFileCard: if c.UUID == card.UUID { return expectf("the unexpandable delta was sent") } } } return nil } // hzCloneScript answers a clone round by round from fixed replies; rounds // past the last get the final one. func hzCloneScript(replies ...[]xfer.Card) *sync.MockTransport { round := 0 return &sync.MockTransport{Handler: func(*xfer.Message) *xfer.Message { cards := replies[min(round, len(replies)-1)] round++ return &xfer.Message{Cards: cards} }} }