Artifact 2a92427e39acee8cf2fb2c98acfdedfa794afeef35ca91e973ff374a8dda3483:
- File
hub/clone_concurrency_test.go
— part of check-in
[b251b87613]
at
2026-05-04 19:17:37
on branch trunk
— fix(leaf,hub): drop SetMaxOpenConns(1) to unblock concurrent clones (#122)
The applySQLiteTuning helper added in #108 (and propagated to hub/ in #109) capped the libfossil DB connection pool at 1 connection. The intent was to make libfossil's per-connection PRAGMAs stick, but libfossil's clone path has internal goroutines that all need a DB connection — capping the pool deadlocks every concurrent clone, with one goroutine holding the conn while another waits for it.
Bones reported (issue #120) that bumping leaf v0.0.6 → v0.0.7 deterministically deadlocks examples/hub-leaf-e2e/'s 3-leaf concurrent clone test. Reproduced here in hub/clone_concurrency_test.go: 3 parallel libfossil.Clone calls against a shared hub. Without the fix the test hangs until its 25s timeout. With the fix it completes in ~80ms.
Keeping the busy_timeout=30000 PRAGMA — that's the correct primitive for concurrent-write safety; SQLite retries SQLITE_BUSY internally up to the timeout. The pool cap was always overkill.
If we later need PRAGMA stickiness across pooled connections, route it through the modernc driver's per-connection init hook instead of a pool cap.
Closes #120 (user: [email protected], size: 2706) [annotate] [blame] [check-ins using]
package hub import ( "context" "path/filepath" "sync" "testing" "time" libfossil "github.com/danmestas/libfossil" ) // TestHub_ConcurrentClones reproduces issue #120: the leaf v0.0.7 // `applySQLiteTuning` call sets MaxOpenConns(1), which deadlocks // libfossil's clone path because the clone has internal goroutines // that all need the DB connection. // // Bones reported the deadlock from `examples/hub-leaf-e2e/` with N // concurrent clones against a shared hub repo. Reproduced here with // 3 concurrent clones; the deadlock is deterministic so 3 is enough. // // This test must complete in < 30s. Without the fix it hangs forever // (all clone goroutines wait on the single shared conn). func TestHub_ConcurrentClones(t *testing.T) { h := newTestHub(t) // Seed the hub repo with one commit so there's something to clone. if _, err := h.Commit(context.Background(), CommitOpts{ Files: []FileToCommit{{Name: "seed.txt", Content: []byte("v1")}}, Message: "seed", Author: "hub", }); err != nil { t.Fatalf("seed Commit: %v", err) } // Add a user the clone clients can authenticate as. Default caps for // "anonymous" are too narrow for clone in some builds; explicit user // avoids that variability. if err := h.AddUser(User{Login: "client", Caps: "asghbcofkimnqru"}); err != nil { t.Fatalf("AddUser: %v", err) } // Run hub HTTP serving in a goroutine. serveCtx, cancelServe := context.WithCancel(context.Background()) t.Cleanup(cancelServe) go func() { _ = h.ServeHTTP(serveCtx) }() // Wait briefly for the listener to be live. time.Sleep(50 * time.Millisecond) hubURL := "http://" + h.HTTPAddr() + "/" dir := t.TempDir() const N = 3 done := make(chan error, N) var wg sync.WaitGroup wg.Add(N) for i := range N { go func() { defer wg.Done() cloneCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() path := filepath.Join(dir, "leaf-"+itoa(i)+".fossil") r, _, err := libfossil.Clone(cloneCtx, path, libfossil.NewHTTPTransport(hubURL), libfossil.CloneOpts{ User: "client", Password: "", }) if r != nil { _ = r.Close() } done <- err }() } // Whole test must complete within 25s; without the fix all N clones hang. hardDeadline := time.After(25 * time.Second) completed := 0 for completed < N { select { case err := <-done: if err != nil { t.Errorf("clone error: %v", err) } completed++ case <-hardDeadline: t.Fatalf("deadlock: only %d/%d clones completed within 25s", completed, N) } } wg.Wait() } func itoa(i int) string { switch i { case 0: return "0" case 1: return "1" case 2: return "2" case 3: return "3" } return "n" }