mesh-build-go takes a download again after a network failure, three times with a short wait, and says so (issue 1921, issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-06 10:09:35 +01:00
parent b02868f209
commit 77844477ec
3 changed files with 89 additions and 5 deletions
@@ -41,6 +41,8 @@ type Module struct {
// Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not).
Ctx context.Context
Say func(format string, a ...any)
// Pause waits between a download and its retaking (versions.go); nil waits on a timer. A test sets it to record.
Pause func(d time.Duration)
// Run runs the commands of a step (StepRunner); Work holds the workspaces and Cache the module and build caches,
// both outside the account's home (ADR 0466 §1); Home is the account's home.
Run forgekey.Runner
@@ -30,8 +30,10 @@ import (
"strconv"
"strings"
"sync"
"time"
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// TakenVersion is what the fetch built a directory against for one asked package: the asked commit, the version its go.mod
@@ -95,6 +97,39 @@ func saidSuffix(said string) string {
return ": " + said
}
// downloadAttempts bounds the downloads of one asked version, and downloadBackoff is the wait after the first failed one
// (twice that after the second): a network failure is taken again, any other fails at once.
const (
downloadAttempts = 3
downloadBackoff = 3 * time.Second
)
// networkFailure says go's words are those of a network that did not answer (a name lookup, a timeout, a refused or
// reset connection), not of a version or a checksum that is wrong.
func networkFailure(s string) bool {
s = strings.ToLower(s)
for _, w := range []string{"i/o timeout", "no such host", "temporary failure in name resolution", "connection refused", "connection reset", "tls handshake timeout", "lookup "} {
if strings.Contains(s, w) {
return true
}
}
return false
}
// retryWait waits d (m.Pause in a test) or until ctx ends.
func (m *Module) retryWait(ctx context.Context, d time.Duration) error {
if m.Pause != nil {
m.Pause(d)
return ctx.Err()
}
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// takeVersion takes one asked version into dir's modfile (takeVersions).
func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVersion, pins *sync.Mutex) error {
modfile, err := m.ensureModfile(dir)
@@ -103,13 +138,34 @@ func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVer
}
pinsDir := filepath.Dir(modfile)
tagged := sdkstep.PackageSemver.MatchString(tv.Asked)
r := m.Run(ctx, m.goEnv(), "go", "-C", pinsDir, "mod", "download", "-json", tv.Package+"@"+tv.Asked)
var r forgekey.Ran
var info downloadInfo
_ = json.Unmarshal([]byte(r.Stdout), &info)
failed := r.Status != 0 || r.Err != "" || info.Version == "" || info.Error != ""
var failed bool
attempts := 0
for {
attempts++
r = m.Run(ctx, m.goEnv(), "go", "-C", pinsDir, "mod", "download", "-json", tv.Package+"@"+tv.Asked)
info = downloadInfo{}
_ = json.Unmarshal([]byte(r.Stdout), &info)
failed = r.Status != 0 || r.Err != "" || info.Version == "" || info.Error != ""
if !failed || attempts >= downloadAttempts || !networkFailure(info.Error+"\n"+r.Err+"\n"+r.Stderr) {
break
}
// A name lookup, a timeout or a refused connection says nothing of the version: the download is taken again.
if m.Say != nil {
m.Say("%s: downloading %s@%s failed on the network (attempt %d of %d), taken again: %s", filepath.Base(dir), tv.Package, tv.Asked, attempts, downloadAttempts, causeLine(info.Error+"\n"+r.Err+"\n"+r.Stderr))
}
if err := m.retryWait(ctx, time.Duration(attempts)*downloadBackoff); err != nil {
return err
}
}
tried := "taken once"
if attempts > 1 {
tried = fmt.Sprintf("taken %d times", attempts)
}
if failed && tagged {
failure := strings.TrimSpace(info.Error + "\n" + r.Err + "\n" + r.Stderr)
return fmt.Errorf("downloading the group's tagged version %s of %s through the Go seat failed (a download failure that may be retaken): %s", tv.Asked, tv.Package, causeLine(failure))
return fmt.Errorf("downloading the group's tagged version %s of %s through the Go seat failed (a download failure, %s, that may be retaken by a new ask of this fingerprint): %s", tv.Asked, tv.Package, tried, causeLine(failure))
}
if failed {
// Not a commit Go can fetch: the head of a group's pull request (ADR 0573 §4), placed from the forge's heads.
@@ -127,7 +183,7 @@ func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVer
pins.Unlock()
// On no head, and go failed for another cause (the forge or the resolver did not answer): it fails with go's own
// words, and not as a kind the builder holds until the module changes (issue 1748).
return fmt.Errorf("downloading the asked version %s of %s failed (a download failure that may be retaken): %s", tv.Asked, tv.Package, causeLine(failure))
return fmt.Errorf("downloading the asked version %s of %s failed (a download failure, %s, that may be retaken by a new ask of this fingerprint): %s", tv.Asked, tv.Package, tried, causeLine(failure))
}
if err == nil {
err = m.replaceInModfile(dir, tv.Package, where)
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
@@ -28,6 +29,7 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) {
built, said, edit string
failsWith string // a download failure that is not an unknown revision: the step fails with go's own words
forgeDown bool // the forge's listing of pull request heads fails too
timeouts, downloads int // downloads that time out before go answers; the downloads asked in all (0: not counted)
}{
{name: "a trunk commit newer than go.mod's", asked: trunk,
answer: `{"Path":"` + sdk + `","Version":"v0.1.15-0.20261101000000-aaaaaaaaaaaa","Sum":"h1:zip=","GoModSum":"h1:mod="}`,
@@ -61,12 +63,23 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) {
built: "v0.1.14-feat-x.2", said: "the group's tagged version", edit: "-require=" + sdk + "@v0.1.14-feat-x.2"},
{name: "a tagged version the Go seat lacks", asked: "v0.1.15-feat-x.3", failing: true, failsWith: "the group's tagged version v0.1.15-feat-x.3",
answer: `{"Path":"` + sdk + `","Error":"` + sdk + `@v0.1.15-feat-x.3: reading https://seat/go/@v/v0.1.15-feat-x.3.info: 404 Not Found"}`},
// Issue 1966: a network failure is taken again a bounded number of times; another failure is not.
{name: "a timeout twice is taken again", asked: trunk, timeouts: 2, downloads: 3,
answer: `{"Path":"` + sdk + `","Version":"v0.1.15-0.20261101000000-aaaaaaaaaaaa","Sum":"h1:zip=","GoModSum":"h1:mod="}`,
built: "v0.1.15-0.20261101000000-aaaaaaaaaaaa", edit: "-require=" + sdk + "@v0.1.15-0.20261101000000-aaaaaaaaaaaa"},
{name: "a timeout that stays fails after three downloads", asked: offHead, failing: true, failsWith: "taken 3 times", downloads: 3,
answer: `{"Path":"` + sdk + `","Error":"lookup git.novox.be on 10.10.0.3:53: i/o timeout"}`},
{name: "an unknown revision is not taken again", asked: offHead, failing: true, failsWith: "on no pull request head", downloads: 1,
answer: `{"Path":"` + sdk + `","Error":"invalid version: unknown revision ` + pinShort + `"}`},
{name: "a refused connection is no pin", asked: offHead, failing: true, failsWith: "connection refused",
answer: `{"Path":"` + sdk + `","Error":"git ls-remote -q origin in /tmp/gopath: dial tcp 10.10.0.1:443: connect: connection refused"}`},
} {
t.Run(c.name, func(t *testing.T) {
h, states := newPinHarness(t, pinnedGoMod, false, pullList)
h.f.gomods = "go.mod\nother/go.mod\n"
var paused []time.Duration
h.m.Pause = func(d time.Duration) { paused = append(paused, d) }
downloads := 0
base := h.m.Run
h.m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := name + " " + strings.Join(args, " ")
@@ -89,6 +102,10 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) {
h.mu.Lock()
h.cmds = append(h.cmds, cmdRecord{env: env, line: line})
h.mu.Unlock()
downloads++
if downloads <= c.timeouts {
return forgekey.Ran{Status: 1, Stdout: `{"Path":"` + sdk + `","Error":"lookup git.novox.be on 10.10.0.3:53: i/o timeout"}`}
}
if c.failing {
return forgekey.Ran{Status: 1, Stdout: c.answer}
}
@@ -101,6 +118,15 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) {
t.Fatal(err)
}
st := states.waitEnd(t)
if c.downloads != 0 && (downloads != c.downloads || len(paused) != c.downloads-1) {
t.Errorf("go downloaded %d times with %d waits, want %d downloads", downloads, len(paused), c.downloads)
}
if c.failsWith == "on no pull request head" { // not a network failure: the forge's heads said, no retaking
if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, c.failsWith) {
t.Errorf("the unknown revision ended %s: %q", st.State, st.Reason)
}
return
}
if c.failsWith != "" {
if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, c.failsWith) || strings.Contains(st.Reason, "no pull request head") ||
!strings.Contains(st.Reason, "may be retaken") || builds.FailedUntilChanged(st.Reason) {