Artifact ae0bee9cfd1cf207d17f4c21d1dba985f1b1b924d2536646e0085b33f2a20362:
- File
internal/manifest/crosslink_cancel_test.go
— part of check-in
[a02c9572da]
at
2026-07-23 16:18:24
on branch trunk
— clone: add --timeout override and make the deadline reliably fire (#120) (#144)
The clone command hardcoded a 10-minute context deadline with no way to raise or disable it, and the deadline did not reliably interrupt a slow clone.
- RepoCloneCmd gains a --timeout flag (default 10m, matching the old behavior; 0 disables the deadline for very large repositories). cloneContext branches away from context.WithTimeout for a non-positive timeout -- WithTimeout(parent, 0) yields an already-expired deadline, which would abort instantly the very clone the flag exists to unbound.
- The whole-repository crosslink sweep now observes context cancellation. It runs after the exchange-round loop, so it had no round boundary of its own at which the deadline could fire -- on a large repository it could run long past the deadline uninterrupted. manifest.CrosslinkContext checks the context every 256 candidates; manifest.Crosslink stays a background-context wrapper so its ~30 existing callers are unchanged. processResponse gains the same batched check so a large single-round batch is interruptible too, and the sync session's and the serve handler's post-convergence sweeps are wired to their contexts for the same reason.
Each cancellation point is pinned by a test that fails without it: the crosslink and processResponse tests drive a context that survives the first poll and dies before the second, so only the batched check can observe it; the crosslink-sweep test cancels after a zero-card final round, so nothing before the sweep is left to notice.
Also adds a clone -> serve -> reclone fidelity regression guard (#114), built with the canonical fossil binary rather than this library's Commit. Commit does not deltify, so a corpus built through it holds zero delta rows and never reaches the serve side's deltified-row expansion branch -- a green result from such a corpus proves nothing about #114. The guard now asserts the source holds delta rows before measuring anything, and compares the second-generation clone's tip, artifact count, blob-store size, delta-row count and checked-out tree content against the source.
Measured against that corpus (71 artifacts, 54 delta rows), #114 does not reproduce: gen1 and gen2 both hold the same 54 delta rows, the same tip and artifact count, a 1.009 store ratio, identical checked-out content, and pass fossil test-integrity. The receive path re-deltifies what the serve side expanded, via content.Deltify on the crosslink mlink path, so the expansion the serve side performs does not survive into the store. (user: [email protected], size: 4420) [annotate] [blame] [check-ins using]
package manifest import ( "context" "errors" "fmt" "testing" "github.com/danmestas/go-libfossil/internal/blob" ) // pollCancelCtx is a context that reports live for its first livePolls calls to // Done() and cancelled from then on. The sweep polls its context once every // crosslinkCancelCheckStride candidates, so a context that survives the first // poll and dies before the second can only be observed by the *batched* check // -- the i==0 check alone can never see it. That is what makes the test below // discriminate: neuter the stride and this context is never sampled a second // time, so the sweep runs to completion and the test fails. type pollCancelCtx struct { context.Context livePolls int polls int live chan struct{} // never closed dead chan struct{} // closed at construction } func newPollCancelCtx(livePolls int) *pollCancelCtx { dead := make(chan struct{}) close(dead) return &pollCancelCtx{ Context: context.Background(), livePolls: livePolls, live: make(chan struct{}), dead: dead, } } func (c *pollCancelCtx) Done() <-chan struct{} { c.polls++ if c.polls <= c.livePolls { return c.live } return c.dead } func (c *pollCancelCtx) Err() error { if c.polls <= c.livePolls { return nil } return context.Canceled } // TestCrosslinkContextObservesCancellationMidSweep pins that the crosslink // sweep's *batched* cancellation check actually fires. Crosslink is the one // phase of a clone that walks the whole received repository in a single call // with no round boundary to fall back on, so without an in-loop check a clone // deadline cannot interrupt it -- the "ran long past its deadline, never // completed" symptom of #120 at large-repository scale. // // The corpus is deliberately larger than crosslinkCancelCheckStride so the // sweep polls its context more than once, and the context is live for the first // poll (i == 0) and cancelled for the second (i == stride). A sweep that only // checked at i == 0 -- or whose stride were large enough to never sample again // -- would run to completion and return a nil error here. func TestCrosslinkContextObservesCancellationMidSweep(t *testing.T) { r := setupTestRepo(t) // Comfortably more than crosslinkCancelCheckStride, so the sweep polls its // context at least twice. Spelled as a literal rather than derived from the // stride on purpose: a corpus that shrank or grew in lockstep with the // stride could never detect the stride being neutered. const candidates = 768 for i := range candidates { if _, _, err := blob.Store(r.DB(), fmt.Appendf(nil, "candidate blob %d, not a manifest", i)); err != nil { t.Fatalf("blob.Store(%d): %v", i, err) } } ctx := newPollCancelCtx(1) linked, err := CrosslinkContext(ctx, r) if !errors.Is(err, context.Canceled) { t.Fatalf("CrosslinkContext error = %v, want context.Canceled "+ "(the batched in-loop check did not fire; polls=%d)", err, ctx.polls) } if ctx.polls < 2 { t.Errorf("context polled %d time(s); the sweep must sample it more than "+ "once for the batched check to mean anything", ctx.polls) } if linked != 0 { t.Errorf("linked = %d, want 0 (no candidate here is a real manifest)", linked) } } // TestCrosslinkContextObservesPreCancelledContext keeps the cheap i == 0 case // honest: an already-cancelled context aborts before any work is done. func TestCrosslinkContextObservesPreCancelledContext(t *testing.T) { r := setupTestRepo(t) if _, _, err := blob.Store(r.DB(), []byte("candidate blob, not a manifest")); err != nil { t.Fatalf("blob.Store: %v", err) } ctx, cancel := context.WithCancel(context.Background()) cancel() linked, err := CrosslinkContext(ctx, r) if !errors.Is(err, context.Canceled) { t.Fatalf("CrosslinkContext error = %v, want context.Canceled", err) } if linked != 0 { t.Errorf("linked = %d, want 0 on immediate cancellation", linked) } } // TestCrosslinkStillWorksWithoutContext keeps the historical Crosslink entry // point honest: it must behave exactly as before, supplying its own background // context, so the ~30 existing callers need no change. func TestCrosslinkStillWorksWithoutContext(t *testing.T) { r := setupTestRepo(t) if _, _, err := blob.Store(r.DB(), []byte("candidate blob, not a manifest")); err != nil { t.Fatalf("blob.Store: %v", err) } if _, err := Crosslink(r); err != nil { t.Fatalf("Crosslink: %v", err) } }