Artifact cd39166409e9619f87157a306d70fe5ac878b61d3a302e1d189029d93fb43b54:
- File
internal/sync/ckin_lock.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: 6372) [annotate] [blame] [check-ins using]
package sync import ( "database/sql" "errors" "strings" "time" "github.com/danmestas/go-libfossil/db" ) // DefaultCkinLockTimeout is the default duration after which a ci-lock expires. const DefaultCkinLockTimeout = 60 * time.Second // lockTimeout is the repository's lock-timeout setting in seconds, read as // fossil's db_get_int("lock-timeout", 60) reads it (§9.4): absent gives 60, // and otherwise SQLite's integer conversion applies, so a value that is not // a number reads as 0. A timeout of 0 or less makes every lock stale at the // next ci-lock, which is what fossil does with such a setting. func lockTimeout(d *db.DB) time.Duration { var n int64 err := d.QueryRow("SELECT CAST(value AS INTEGER) FROM config" + " WHERE name='lock-timeout'").Scan(&n) if err != nil { return DefaultCkinLockTimeout } return time.Duration(n) * time.Second } // forbidDeltaManifests reads the forbid-delta-manifests setting as fossil // 2.29's db_get_boolean("forbid-delta-manifests", 1) does (§9.4): absent or // NULL is on, and a value that is neither true nor false keeps that default. // fossil 2.28's server reads it with a default of off, so it sends no // avoid-delta-manifests unless the setting is on; this follows the draft. func forbidDeltaManifests(d *db.DB) bool { if d == nil { panic("sync.forbidDeltaManifests: d must not be nil") } var value sql.NullString err := d.QueryRow("SELECT value FROM config" + " WHERE name='forbid-delta-manifests'").Scan(&value) if err != nil || !value.Valid { return true } return settingBoolean(value.String, true) } // settingBoolean is fossil's db_get_boolean on a stored value: is_truth // ("on", "yes", "true" in any case, or a nonzero atoi) gives true, is_false // ("off", "no", "false", "0" in any case) gives false, and anything else // gives dflt. func settingBoolean(value string, dflt bool) bool { switch strings.ToLower(value) { case "on", "yes", "true": return true case "off", "no", "false", "0": return false } if cAtoi(value) != 0 { return true } return dflt } // cAtoi is C's atoi as fossil's is_truth uses it: leading white space, an // optional sign, then decimal digits up to the first other byte; no digits // give 0. Overflow is not modelled, since only zero versus nonzero matters. func cAtoi(s string) int64 { i := 0 for i < len(s) && strings.IndexByte(" \t\n\v\f\r", s[i]) >= 0 { i++ } negative := false if i < len(s) && (s[i] == '+' || s[i] == '-') { negative = s[i] == '-' i++ } var n int64 for ; i < len(s) && s[i] >= '0' && s[i] <= '9'; i++ { if n < 1<<40 { n = n*10 + int64(s[i]-'0') } } if negative { return -n } return n } // CkinLockFail reports that another client holds the check-in lock. type CkinLockFail struct { HeldBy string Since time.Time } // ckinLockPrefix names a check-in lock's config row, as fossil's ci-lock // does: ci-lock-HASH, holding JSON {"login":...,"clientid":...}, with the // time the lock was taken in the mtime column (§9.4). A repository served by // both fossil and this server so has one lock table. const ckinLockPrefix = "ci-lock-" func configKey(parentUUID string) string { return ckinLockPrefix + parentUUID } // releaseCkinLocks drops the check-in locks clientID holds, and any lock // whose record is not JSON, as fossil's pragma ci-unlock does (§9.4). func releaseCkinLocks(d *db.DB, clientID string) { d.Exec("DELETE FROM config WHERE name GLOB 'ci-lock-*'"+ " AND (NOT json_valid(value) OR json_extract(value, '$.clientid')=?)", clientID) } // processCkinLock handles a ci-lock pragma request, as fossil's page_xfer // does: stale locks go first; a live lock on parentUUID is kept as it is, // with no refresh, and reported unless clientID holds it; with none, a lock // is written for user and clientID, unacknowledged (§9.4). An empty user is // nobody, whose login fossil stores as JSON null. func processCkinLock(d *db.DB, parentUUID, clientID, user string, timeout time.Duration) *CkinLockFail { if parentUUID == "" { panic("sync.processCkinLock: empty parentUUID") } expireStaleLocks(d, timeout) var login, holder string var since int64 // mtime is cast: the column is declared DATETIME, which some drivers // would otherwise hand back as a time. err := d.QueryRow("SELECT coalesce(json_extract(value, '$.login'), ''),"+ " coalesce(json_extract(value, '$.clientid'), ''), CAST(mtime AS INTEGER)"+ " FROM config"+ " WHERE name=? AND json_valid(value)", configKey(parentUUID)). Scan(&login, &holder, &since) if err == nil { if holder != clientID { return &CkinLockFail{HeldBy: login, Since: time.Unix(since, 0)} } return nil } if !errors.Is(err, sql.ErrNoRows) { // A lock that cannot be read is not overwritten: the pragma has no // error reply, and taking the lock could clobber another client's. return nil } d.Exec("REPLACE INTO config(name, value, mtime)"+ " VALUES(?, json_object('login', nullif(?, ''), 'clientid', ?), ?)", configKey(parentUUID), user, clientID, time.Now().Unix()) return nil } // expireStaleLocks removes the ci-lock rows at least timeout old, or whose // check-in is no longer a leaf. As in fossil, only rows holding JSON are // considered; ci-unlock is what clears the others. func expireStaleLocks(d *db.DB, timeout time.Duration) { cutoff := time.Now().Unix() - int64(timeout.Seconds()) // Locks written under the old libfossil-ci-lock- key match neither this // sweep nor ci-unlock, so nothing else would ever clear them. d.Exec("DELETE FROM config WHERE name GLOB 'libfossil-ci-lock-*'") d.Exec("DELETE FROM config WHERE name GLOB 'ci-lock-*' AND json_valid(value)"+ " AND mtime <= ?", cutoff) rows, err := d.Query("SELECT name FROM config WHERE name GLOB 'ci-lock-*'" + " AND json_valid(value)") if err != nil { return } defer rows.Close() var toDelete []string for rows.Next() { var name string if rows.Scan(&name) != nil { continue } uuid := strings.TrimPrefix(name, ckinLockPrefix) var rid int64 if d.QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(&rid) != nil { toDelete = append(toDelete, name) continue } var dummy int if d.QueryRow("SELECT 1 FROM leaf WHERE rid=?", rid).Scan(&dummy) != nil { toDelete = append(toDelete, name) } } for _, name := range toDelete { d.Exec("DELETE FROM config WHERE name=?", name) } }