Artifact c998045f35d516e4fdc3e2978adb53879d12b424213f61fb4d0fbb769d01a936:
- File
conformance/sync_client_uv_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: 8982) [annotate] [blame] [check-ins using]
package conformance import ( "fmt" "strings" "github.com/danmestas/go-libfossil/db" ) // uvRows adds unversioned rows to the client, then drops its cached // catalog hash so they count. Content is stored uncompressed. func uvRows(rows ...string) []string { out := append([]string{}, quiet...) for _, r := range rows { out = append(out, "INSERT INTO unversioned"+ "(name, rcvid, mtime, hash, sz, encoding, content) VALUES("+r+")") } return append(out, "DELETE FROM config WHERE name='uv-hash'") } // uvN1 is the values of an unversioned row named name holding N1's // content, uncompressed, at mtime. func uvN1(name, mtime string) string { return "'" + name + "', 1, " + mtime + ", '{N1}', 15, 0, CAST('a new artifact'||char(10) AS BLOB)" } // catalogHash checks request 1's uv-hash against Section 8.5.1's formula, // computed here from the client's rows: the SHA-1 of "NAME YYYY-MM-DD // HH:MM:SS HASH" LF per live row, sorted by name bytes. func catalogHash(r *clientRun) error { conn, err := db.OpenSQL(r.repo, db.OpenConfig{}, nil) if err != nil { return err } defer conn.Close() rows, err := conn.Query("SELECT name, datetime(mtime, 'unixepoch'), hash FROM unversioned" + " WHERE hash IS NOT NULL ORDER BY name COLLATE BINARY") if err != nil { return err } defer rows.Close() var text strings.Builder for rows.Next() { var name, when, hash string if err := rows.Scan(&name, &when, &hash); err != nil { return err } fmt.Fprintf(&text, "%s %s %s\n", name, when, hash) } if err := rows.Err(); err != nil { return err } return sent(1, "pragma uv-hash "+sha1Hex(text.String()))(r) } // clientUVCases are Section 8.5.1's send table, uv-hash and catalog hash. func clientUVCases() []syncClientCase { uvSync := clientOp{pull: true, push: true, uv: true} return []syncClientCase{{ Case: Case{ID: "sync.8.5.1.seed-excludes-tombstones", Req: "SP-8.5.1-1"}, op: uvSync, setup: uvRows("'t.txt', 1, {UVT}, NULL, 0, 0, NULL"), replies: [][]string{reply("pragma uv-push-ok")}, want: every(sent(2, "uvfile u.txt {UVT} {UVH} 11 0"), neverSent("uvfile t.txt")), fc: true, }, { // uvfile, with six tokens, is the longest card a client sends. Case: Case{ID: "sync.3.9.sender-six-tokens-client", Req: "SP-3.9-1"}, op: clientOp{pull: true, push: true, uv: true, login: true}, setup: quiet, replies: [][]string{reply("pragma uv-push-ok")}, want: every(sent(2, "uvfile u.txt {UVT} {UVH} 11 0"), eachCardSent(sixTokens)), fc: true, }, { Case: Case{ID: "sync.8.5.1.uv-hash-once", Req: "SP-8.5.1-2"}, op: uvSync, setup: quiet, replies: [][]string{reply("igot {U}"), reply("igot {U2}")}, want: every(atLeast(3), sent(1, "pragma uv-hash {UVCAT}"), notSent(2, "pragma uv-hash"), notSent(3, "pragma uv-hash")), fc: true, }, { // Names that sort differently by bytes and by case, and a // tombstone, which the hash leaves out. Case: Case{ID: "sync.8.5.1.catalog-hash", Req: "SP-8.5.1-3"}, op: uvSync, setup: uvRows(uvN1("a", "{UVT}"), uvN1("B", "{UVT1}"), "'t.txt', 1, {UVT}, NULL, 0, 0, NULL"), replies: [][]string{reply()}, want: catalogHash, fc: true, }, { Case: Case{ID: "sync.8.5.1.pull-only-drops", Req: "SP-8.5.1-6"}, op: uvSync, setup: uvRows(uvN1("v.txt", "{UVT1}")), replies: [][]string{reply("pragma uv-pull-only", "uvigot u.txt 1767225000 {UVH} 11", "uvigot v.txt {UVT} {UVH} 11")}, want: neverSent("uvfile "), fc: true, }} } // clientUVStatusCases are Table 6: what a client does for each status a // uvigot has against its own row. func clientUVStatusCases() []syncClientCase { return []syncClientCase{{ // s0.txt is not held (status 0); u.txt is held with other content // and is older (status 1), so it is deleted and asked for. Case: Case{ID: "sync.8.5.1.status-0-1", Req: "SP-8.5.1-5"}, op: clientOp{pull: true, uv: true}, replies: [][]string{reply("pragma uv-pull-only", "uvigot s0.txt {UVT} {N1} 15", "uvigot u.txt {UVT1} {N1} 15")}, want: every(sent(2, "uvgimme s0.txt"), sent(2, "uvgimme u.txt"), clientRows("SELECT count(*) FROM unversioned WHERE name='u.txt'", 0)), }, { Case: Case{ID: "sync.8.5.1.status-2", Req: "SP-8.5.1-5"}, op: clientOp{pull: true, uv: true}, replies: [][]string{reply("pragma uv-pull-only", "uvigot u.txt {UVT1} {UVH} 11")}, want: every(neverSent("uvgimme "), clientRows("SELECT count(*) FROM unversioned WHERE name='u.txt' AND mtime={UVT1}", 1)), }, { // u.txt is the peer's content, older (status 4): only its mtime is // sent. v.txt is other content, newer (status 5): it is sent // whole. w.txt matches (status 3): nothing is sent. Case: Case{ID: "sync.8.5.1.status-3-4-5", Req: "SP-8.5.1-5"}, op: clientOp{pull: true, push: true, uv: true}, setup: uvRows(uvN1("v.txt", "{UVT1}"), uvN1("w.txt", "{UVT}")), replies: [][]string{reply("pragma uv-push-ok", "uvigot u.txt 1767225000 {UVH} 11", "uvigot v.txt {UVT} {UVH} 11", "uvigot w.txt {UVT} {N1} 15")}, want: every(sent(2, "uvfile u.txt {UVT} {UVH} 11 4"), sentPayload(2, "uvfile v.txt {UVT1} {N1} 15 0", "{N1.data}"), notSent(2, "uvfile w.txt")), fc: true, }} } // clientMessageCases are Sections 9 and 10 on the client: pragmas, error // and message cards. func clientMessageCases() []syncClientCase { stored1 := []string{"file {N1} {N1.size}", "{N1.data}"} return []syncClientCase{{ Case: Case{ID: "sync.9.unknown-pragma", Req: "SP-9-1"}, op: opPull, replies: [][]string{reply(append([]string{"pragma zzz a b c d e"}, stored1...)...)}, want: stored("N1"), fc: true, }, { // The byte after a config payload is consumed whatever it is. Case: Case{ID: "sync.9.3.config-opaque", Req: "SP-9.3-4"}, op: opPull, replies: [][]string{reply(append([]string{"config /zzz 3", "{CFGX.data}"}, stored1...)...)}, want: stored("N1"), fc: true, }, { Case: Case{ID: "sync.9.wrong-direction-client", Req: "SP-9-2"}, op: opPull, replies: [][]string{reply(append([]string{"pragma client-version 1"}, stored1...)...)}, want: stored("N1"), fc: true, }, { Case: Case{ID: "sync.9.5.send-private-every-round", Req: "SP-9.5-2"}, op: clientOp{pull: true, private: true}, replies: [][]string{reply("igot {U}"), reply("igot {U2}")}, want: every(atLeast(3), eachRequest(func(i int, req peerRequest) error { if index(req.cards, "pragma send-private") < 0 { return expectf("request %d has no send-private", i) } return nil })), fc: true, }, { Case: Case{ID: "sync.10.1.error-prefix", Req: "SP-10.1-1"}, op: opPull, replies: [][]string{reply(append(stored1, "error boom", "igot {U}")...)}, want: every(stored("N1"), notStored("U"), neverSent("gimme {U}")), fc: true, }, { // N1 came in the first round; an error in the second keeps it, // and nothing continues the session. Case: Case{ID: "sync.10.1.later-error-prefix", Req: "SP-10.1-1"}, op: opSync, setup: quiet, replies: [][]string{reply(append(stored1, "gimme {A1}")...), reply("error x")}, want: every(stored("N1"), rounds(2)), fc: true, }, { // A wrong hash found before storage is a handler error, not a failed // commit: the igot before it still leaves its phantom (#418). Case: Case{ID: "sync.10.1.integrity-error-prefix", Req: "SP-10.1-6"}, op: opPull, replies: [][]string{reply("igot {U}", "file {N1} {A1} {DTA1.size}", "{DTA1.data}")}, want: every(phantom("U"), notStored("N1"), mentions("mismatch", "wrong hash")), fc: true, fossil: "fossil(1) 2.28 stores the content after the error and aborts at " + "commit when verify_before_commit sees the mismatch, which rolls back the " + "reply's prefix with it", }, { // The same reply, but a continuation condition (the igot's phantom) // holds, so the second request must follow the error (§10.1, §3.7). Case: Case{ID: "sync.10.1.integrity-error-continues", Req: "SP-10.1-7"}, op: opPull, replies: [][]string{reply("igot {U}", "file {N1} {A1} {DTA1.size}", "{DTA1.data}")}, want: sent(2, "gimme {U}"), fossil: "fossil(1) 2.28 aborts the round at commit when verify_before_commit " + "sees the mismatch, so it never reaches the continuation", }, { Case: Case{ID: "sync.10.1.error-continues-when-due", Req: "SP-10.1-1"}, op: opPull, replies: [][]string{reply("igot {U}", "error x")}, want: sent(2, "gimme {U}"), fc: true, }, { Case: Case{ID: "sync.10.2.message-advisory", Req: "SP-10.2-1"}, op: opPull, replies: [][]string{reply(append([]string{`message hello\sworld`}, stored1...)...)}, want: every(stored("N1"), reported("hello world")), fc: true, }, { Case: Case{ID: "sync.10.2.message-arity", Req: "SP-10.2-1"}, op: opPull, replies: [][]string{reply("message a b", "igot {U}")}, want: every(rounds(1), notStored("U")), fc: true, }} }