Artifact 87ff97908ad99223694296c1b881f6957068bc73bfdb0c611e6bb52a280c7444:
- File
internal/sync/ckin_lock_test.go
— part of check-in
[600861484f]
at
2026-10-13 15:55:30
on branch main
— test(conformance): re-pin the -01 drafts with the 2026-10-13 errata (#430)
* test(conformance): re-pin the -01 drafts with the 2026-10-13 errata
Re-pin the repo-state and sync-protocol drafts to zit check-in 5a1b741b25d8, which applies five more -01 errata: repo-state §5.3, §7.3 and §8.3, sync-protocol §9.4/§9.5 and §10.1. zit now keeps each -01 text under the -00 file name; the copies keep their -01 names and conformance/rfcs/README.md records the zit path and check-in. The new errata are pinned, and the requirement tables are re-read against every changed hunk.
The §7.3 and §8.3 cases that were deviations now pass as written, and new cases cover a date tag's arrival order (§5.3), untagged leaves and a merge parent's recheck (§7.3).
The sync server answers each guard-passing ci-lock with pragma avoid-delta-manifests while forbid-delta-manifests is on, defaulting to on as the draft and fossil 2.29 do (fossil 2.28 defaults it off). The client leaves its transfer unchanged on that pragma. A file or cfile whose content does not match its hash now fails the client's round, in a pull and a clone: the reply rolls back, prefix included, and no further round starts.
The leaf table is still recomputed per crosslink batch, so a parent whose untagged child arrives in a later batch loses the verdict §7.3 keeps; that case is marked as a gap, tracked in #426.
* fix(sync): continue after a uvfile content error; renumber the avoid-delta-manifests row
A uvfile whose content does not match its HASH is a handler-reported content error: warn, commit the prefix and follow ordinary continuation (sync-protocol-01 10.1). The avoid-delta-manifests client row is SP-9.5-7 now that share-links took SP-9.5-5 and SP-9.5-6. Older unreleased changelog entries drop the pending-erratum wording. (user: [email protected], size: 10890) [annotate] [blame] [check-ins using]
package sync import ( "context" "path/filepath" "testing" "time" "github.com/danmestas/go-libfossil/internal/repo" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/simio" ) func newCkinLockTestRepo(t *testing.T) *repo.Repo { t.Helper() path := filepath.Join(t.TempDir(), "test.fossil") r, err := repo.Create(path, "test", simio.CryptoRand{}, testPC) if err != nil { t.Fatalf("repo.Create: %v", err) } t.Cleanup(func() { r.Close() }) return r } // insertLeafBlob inserts a blob and marks it as a leaf, returning the UUID. func insertLeafBlob(t *testing.T, r *repo.Repo, uuid string) { t.Helper() d := r.DB() d.Exec("INSERT INTO blob(uuid, size, content) VALUES(?, 0, X'')", uuid) var rid int64 if err := d.QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(&rid); err != nil { t.Fatalf("insertLeafBlob: %v", err) } d.Exec("INSERT INTO leaf(rid) VALUES(?)", rid) } func TestCkinLock_Acquire(t *testing.T) { r := newCkinLockTestRepo(t) uuid := "acq00000000000000000000000000000000000000" insertLeafBlob(t, r, uuid) fail := processCkinLock(r.DB(), uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("expected nil, got CkinLockFail{HeldBy: %q}", fail.HeldBy) } } func TestCkinLock_SameClient(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() uuid := "same0000000000000000000000000000000000000" insertLeafBlob(t, r, uuid) fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("first acquire failed: %+v", fail) } fail = processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("same-client re-acquire should succeed, got: %+v", fail) } } func TestCkinLock_Conflict(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() uuid := "conflict0000000000000000000000000000000000" insertLeafBlob(t, r, uuid) fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("first acquire failed: %+v", fail) } fail = processCkinLock(d, uuid, "client-B", "bob", DefaultCkinLockTimeout) if fail == nil { t.Fatal("expected CkinLockFail for different client, got nil") } if fail.HeldBy != "alice" { t.Errorf("HeldBy = %q, want %q", fail.HeldBy, "alice") } } func TestCkinLock_Expiry(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() uuid := "exp00000000000000000000000000000000000000" insertLeafBlob(t, r, uuid) // Acquire lock for client-A. fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("first acquire failed: %+v", fail) } // Backdate the lock's mtime by 120 seconds so it appears stale. key := configKey(uuid) stale := time.Now().Unix() - 120 d.Exec("UPDATE config SET mtime=? WHERE name=?", stale, key) // Different client should now succeed because the lock is expired. fail = processCkinLock(d, uuid, "client-B", "bob", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("expected expired lock to be reaped, got: %+v", fail) } } func TestCkinLock_ParentNotLeaf(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() // Insert a blob that is NOT in the leaf table. uuid := "deadbeef00000000000000000000000000000000" d.Exec("INSERT INTO blob(uuid, size, content) VALUES(?, 0, X'')", uuid) // Acquire lock for client-A on this non-leaf blob. fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("first acquire failed: %+v", fail) } // Different client should succeed because the parent is not a leaf. fail = processCkinLock(d, uuid, "client-B", "bob", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("expected non-leaf parent lock to be cleaned, got: %+v", fail) } } func TestCkinLock_SyncRoundTrip(t *testing.T) { serverRepo := newCkinLockTestRepo(t) // Insert a leaf blob on the server so the lock doesn't get cleaned // up by expireStaleLocks (which removes locks for non-leaf parents). parentUUID := "abcd0000000000000000000000000000000000ab" insertLeafBlob(t, serverRepo, parentUUID) // nobody holds no Write capability and still takes the lock (§9.4). transport := &MockTransport{ Handler: func(req *xfer.Message) *xfer.Message { resp, _ := HandleSync(context.Background(), serverRepo, req) return resp }, } // The client holds the parent it locks, as a client committing on top // of it does. The server's copy has no content to send, and the error a // gimme for it would draw ends the reply before the ci-lock-fail that // follows it in card order. clientRepo := newCkinLockTestRepo(t) insertLeafBlob(t, clientRepo, parentUUID) result, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, CkinLock: &CkinLockReq{ ParentUUID: parentUUID, ClientID: "client-1", }, }) if err != nil { t.Fatalf("Sync: %v", err) } if result.CkinLockFail != nil { t.Fatalf("unexpected lock fail: %+v", result.CkinLockFail) } // Second client should get lock-fail. result2, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, CkinLock: &CkinLockReq{ ParentUUID: parentUUID, ClientID: "client-2", }, }) if err != nil { t.Fatalf("Sync 2: %v", err) } if result2.CkinLockFail == nil { t.Fatal("expected lock fail for second client") } // nobody's lock row has a null login, which ci-lock-fail names nobody. if result2.CkinLockFail.HeldBy != "nobody" { t.Fatalf("HeldBy = %q, want %q", result2.CkinLockFail.HeldBy, "nobody") } } // TestCkinLock_SharesFossilTable pins the lock table to fossil's: a row // fossil's ci-lock wrote (config ci-lock-HASH, JSON login and clientid, the // time in the mtime column) is the lock this server sees, and a live row is // kept as it is, with no refresh, even for the client holding it (§9.4). func TestCkinLock_SharesFossilTable(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() uuid := "f0551100000000000000000000000000000000ab" insertLeafBlob(t, r, uuid) since := time.Now().Unix() - 30 if _, err := d.Exec("INSERT INTO config(name, value, mtime) VALUES(?,"+ " json_object('login', 'bob', 'clientid', 'client-A'), ?)", "ci-lock-"+uuid, since); err != nil { t.Fatal(err) } fail := processCkinLock(d, uuid, "client-B", "alice", DefaultCkinLockTimeout) if fail == nil || fail.HeldBy != "bob" || fail.Since.Unix() != since { t.Fatalf("fossil's lock: got %+v, want held by bob since %d", fail, since) } if fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout); fail != nil { t.Fatalf("same client: got %+v, want no failure", fail) } var login string var mtime int64 if err := d.QueryRow("SELECT json_extract(value, '$.login'), CAST(mtime AS INTEGER)"+ " FROM config WHERE name=?", "ci-lock-"+uuid).Scan(&login, &mtime); err != nil { t.Fatal(err) } if login != "bob" || mtime != since { t.Fatalf("lock now held by %s since %d, want bob since %d", login, mtime, since) } } // TestCkinLock_DropsLegacyRows checks that lock rows under the old // libfossil-ci-lock- key, which no sweep matches any longer, are cleared. func TestCkinLock_DropsLegacyRows(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() d.Exec("INSERT INTO config(name, value, mtime) VALUES(?, ?, ?)", "libfossil-ci-lock-aaaa", "bob|client-B", time.Now().Unix()) uuid := "leg00000000000000000000000000000000000000" insertLeafBlob(t, r, uuid) if fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout); fail != nil { t.Fatalf("unexpected conflict: %+v", fail) } var n int d.QueryRow("SELECT count(*) FROM config WHERE name GLOB 'libfossil-ci-lock-*'").Scan(&n) if n != 0 { t.Fatalf("%d legacy lock rows remain", n) } } // TestLockTimeout_ReadsConfig checks that the lock-timeout setting sets how // long a lock lives, read as fossil's db_get_int reads it: a value that is // not a number is 0, and 0 or less is kept, making every lock stale. func TestLockTimeout_ReadsConfig(t *testing.T) { d := newCkinLockTestRepo(t).DB() if got := lockTimeout(d); got != DefaultCkinLockTimeout { t.Fatalf("unset: %v", got) } for v, want := range map[string]time.Duration{ "300": 300 * time.Second, " 30": 30 * time.Second, "0": 0, "-5": -5 * time.Second, "soon": 0, "45s": 45 * time.Second, } { d.Exec("REPLACE INTO config(name, value, mtime) VALUES('lock-timeout', ?, 1)", v) if got := lockTimeout(d); got != want { t.Errorf("lock-timeout %q: %v, want %v", v, got, want) } } } // TestCkinLock_ClientDefossilizesHolder checks that the client reads // ci-lock-fail's LOGIN as fossilized text, as fossil's client does with // defossilize (§9.4), so a login with a space is reported whole. func TestCkinLock_ClientDefossilizesHolder(t *testing.T) { transport := &MockTransport{ Handler: func(req *xfer.Message) *xfer.Message { return &xfer.Message{Cards: []xfer.Card{&xfer.PragmaCard{ Name: "ci-lock-fail", Values: []string{`bob\ssmith`, "1700000000"}, }}} }, } clientRepo := newCkinLockTestRepo(t) result, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, CkinLock: &CkinLockReq{ ParentUUID: "abcd0000000000000000000000000000000000ab", ClientID: "client-1", }, }) if err != nil { t.Fatalf("Sync: %v", err) } if result.CkinLockFail == nil || result.CkinLockFail.HeldBy != "bob smith" { t.Fatalf("CkinLockFail = %+v, want holder %q", result.CkinLockFail, "bob smith") } } // TestSettingBoolean reads a stored setting as fossil's db_get_boolean does: // is_truth wins, then is_false, and anything else keeps the default. func TestSettingBoolean(t *testing.T) { for _, tc := range []struct { value string dflt bool want bool }{ {"on", false, true}, {"YES", false, true}, {"True", false, true}, {"1", false, true}, {" 2x", false, true}, {"-1", false, true}, {"off", true, false}, {"No", true, false}, {"FALSE", true, false}, {"0", true, false}, {"00", true, true}, {"00", false, false}, {"", true, true}, {"junk", true, true}, {"junk", false, false}, {" 0", true, true}, } { if got := settingBoolean(tc.value, tc.dflt); got != tc.want { t.Errorf("settingBoolean(%q, %t) = %t, want %t", tc.value, tc.dflt, got, tc.want) } } } // TestForbidDeltaManifests: the setting is on unless the repository says // otherwise (§9.4). func TestForbidDeltaManifests(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() if !forbidDeltaManifests(d) { t.Fatal("forbidDeltaManifests with no setting = false, want the default, true") } if _, err := d.Exec("REPLACE INTO config(name, value, mtime)" + " VALUES('forbid-delta-manifests', 'off', 1)"); err != nil { t.Fatal(err) } if forbidDeltaManifests(d) { t.Fatal("forbidDeltaManifests with the setting off = true, want false") } }