From bfbdaf34ea2eee7da781d23d8c357dda308b6c14 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 9 Nov 2026 22:55:34 +0100 Subject: [PATCH] =?UTF-8?q?D2:=20the=20domain=20SDK=20names=20the=20state?= =?UTF-8?q?=20again,=20Ended,=20Passing,=20PassingCause,=20HolderSilent,?= =?UTF-8?q?=20AgainAfter=20and=20Drop=20(ADR=200664=20=C2=A71,=20=C2=A72,?= =?UTF-8?q?=20issue=201748)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared line for the join with milestone G: sdk/buildstep/ask.go (AgainAfter beside AskRuns). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- sdk/buildstep/ask.go | 9 ++++++ sdk/buildstep/ask_test.go | 33 ++++++++++++++++++++ sdk/buildstep/passing.go | 63 +++++++++++++++++++++++++++++++++++++++ sdk/buildstep/steps.go | 17 +++++++++-- 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 sdk/buildstep/passing.go diff --git a/sdk/buildstep/ask.go b/sdk/buildstep/ask.go index 5bdf2647..640c0e36 100644 --- a/sdk/buildstep/ask.go +++ b/sdk/buildstep/ask.go @@ -77,6 +77,15 @@ const ( AskMaxDeliver = AskRuns + 2 ) +// AgainAfter is how long the bus waits before it gives an ask again after its run-th run ended on a transport fault: 1 +// minute after the first, 4 after the second (ADR 0664 §2). It outlasts a per-source penalty of a forge's sshd. +func AgainAfter(run int) time.Duration { + if run < 1 { + run = 1 + } + return time.Minute << (2 * (run - 1)) +} + // StepAskVersion is the layout version of an ask. const StepAskVersion = 1 diff --git a/sdk/buildstep/ask_test.go b/sdk/buildstep/ask_test.go index ce960a1b..aba010d1 100644 --- a/sdk/buildstep/ask_test.go +++ b/sdk/buildstep/ask_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -313,6 +314,35 @@ func TestPauseCapacityAndComposerKey(t *testing.T) { if ComposerKey("novox/r", testCommit, "d", "p", tc, nil) != ComposerKey("novox/r", testCommit, "d", "p", tc, PackageVersions{}) { t.Error("an empty ask changed the key") } + // Issue 1748 (ADR 0664): a transport fault is told from a wrong source by its words; a step given again is no end; the + // bus waits 1 minute after the first run and 4 after the second. + for words, want := range map[string]bool{ + `Get "https://proxy": dial tcp: lookup proxy.golang.org: i/o timeout`: true, + `Head http://store.internal:5100/v2/x: dial tcp 10.10.0.1:5100: i/o timeout`: true, + "kex_exchange_identification: read: Connection reset by peer": true, + "Connection closed by 10.10.0.1 port 222": true, + "npm error code EAI_AGAIN": true, + "npm error code ETIMEDOUT": true, + "npm error code ECONNRESET": true, + (&HolderSilent{Seat: "go-package-registry", Holder: "gopkgs", Node: "ace"}).Error(): true, + "nats: no responders available for request": false, + "fatal: unknown revision or path not in the working tree": false, + "verifying module: checksum mismatch": false, + "./main.go:3:1: syntax error: unexpected }": false, + } { + if PassingCause(words) != want { + t.Errorf("PassingCause(%q) = %v, want %v", words, !want, want) + } + } + if !IsPassing(fmt.Errorf("fetch: %w", &HolderSilent{Seat: "s"})) || IsPassing(errors.New("i/o timeout")) { + t.Error("a HolderSilent is a Passing error and a bare error is none") + } + if Ended(Step{State: StepRunning}) || Ended(Step{State: StepAgain}) || !Ended(Step{State: StepPassed}) || !Ended(Step{State: StepFailed}) { + t.Error("Ended: running and again are no end") + } + if AgainAfter(1) != time.Minute || AgainAfter(2) != 4*time.Minute { + t.Errorf("AgainAfter = %v, %v", AgainAfter(1), AgainAfter(2)) + } } // The part `build-step` is one layout for the three build seats: each seat's fixtures read strictly in the neutral @@ -350,6 +380,9 @@ func TestTheThreeSeatsFixturesFitTheNeutralForms(t *testing.T) { case strings.HasPrefix(f.Of, "state:pauses/paused."): seen++ strict(t, f.Given, &Pause{}) + case strings.HasPrefix(f.Of, "state:pauses/dropped."): + seen++ + strict(t, f.Given, &Drop{}) case strings.HasPrefix(f.Of, "state:pauses/capacity."): seen++ strict(t, f.Given, &Capacity{}) diff --git a/sdk/buildstep/passing.go b/sdk/buildstep/passing.go new file mode 100644 index 00000000..8747d747 --- /dev/null +++ b/sdk/buildstep/passing.go @@ -0,0 +1,63 @@ +package buildstep + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// Passing is a fault of the network between a build node and what it reaches (a name lookup, a timeout, a reset +// connection, a forge's sshd refusing a burst, a store that does not answer), not of the source or the toolchain. A +// build module wraps it, and the shared runner then writes the step again and settles its ask with a delayed nak, up to +// AskRuns runs (ADR 0664 §1, §2). Any other error ends the step failed at once. +type Passing struct{ Err error } + +func (p *Passing) Error() string { return p.Err.Error() } +func (p *Passing) Unwrap() error { return p.Err } + +// IsPassing says err is, or wraps, a Passing. +func IsPassing(err error) bool { + var p *Passing + return errors.As(err, &p) +} + +// HolderSilentWords opens the words of a HolderSilent, so PassingCause reads them from a step's reason too. +const HolderSilentWords = "the holder is silent" + +// HolderSilent is a seat verb that answered "no responders" while the assigner names a holder of the seat: its tool +// runner is being replaced, a moment later it answers. It is a Passing fault (ADR 0664 §1); a seat nothing holds is not. +type HolderSilent struct{ Seat, Holder, Node string } + +func (h *HolderSilent) Error() string { + return fmt.Sprintf("%s: seat %s, held by %s on %s, answered no responders", HolderSilentWords, h.Seat, h.Holder, h.Node) +} + +// Unwrap makes a HolderSilent a Passing. +func (h *HolderSilent) Unwrap() error { return &Passing{Err: errors.New(h.Error())} } + +// passingWords are the words of a network that did not answer, lower-cased: go's and git's (a lookup, a timeout, a refused or +// reset connection, ssh's key exchange cut by a forge's per-source penalty) and npm's error codes. "no responders" alone is not +// one: it is the same for a seat nothing holds (HolderSilent is the case a holder is named). +var passingWords = []string{"i/o timeout", "no such host", "temporary failure in name resolution", "connection refused", + "connection reset", "tls handshake timeout", "lookup ", "kex_exchange_identification", "connection closed by", + "eai_again", "etimedout", "econnreset", "econnrefused", "enotfound", strings.ToLower(HolderSilentWords)} + +// PassingCause says words (a tool's output or a step's reason) are those of a transport fault. +func PassingCause(words string) bool { + words = strings.ToLower(words) + for _, w := range passingWords { + if strings.Contains(words, w) { + return true + } + } + return false +} + +// Drop is the value of the key dropped. of a build seat's pauses state: an ask its caller no longer waits for. +type Drop struct { + V int `json:"v,omitempty"` + By string `json:"by,omitempty"` + Why string `json:"why"` + At time.Time `json:"at,omitzero"` +} diff --git a/sdk/buildstep/steps.go b/sdk/buildstep/steps.go index 11a78687..1da18405 100644 --- a/sdk/buildstep/steps.go +++ b/sdk/buildstep/steps.go @@ -23,6 +23,9 @@ var ErrUnknownVersion = errors.New("buildstep: unknown value version") // The states of a step. const ( StepRunning = "running" + // StepAgain is a step that ended on a transport fault with runs left: the bus gives its ask again after AgainAt + // (ADR 0664 §1). It is not an end (Ended), and nothing acknowledges the ask. + StepAgain = "again" StepPassed = "passed" StepFailed = "failed" StepRefused = "refused" @@ -47,8 +50,18 @@ type Step struct { Phase string `json:"phase,omitempty"` PhaseAt time.Time `json:"phase-at,omitzero"` Owner string `json:"owner,omitempty"` + // Run is which run of the ask this is, counted by the bus's delivery count (1 for the first); AgainAt when the bus + // gives it again (the state again); Passing says the step ended on a transport fault, so a failed end after the + // last run says so (ADR 0664 §1, §2). + Run int `json:"run,omitempty"` + AgainAt time.Time `json:"again-at,omitzero"` + Passing bool `json:"passing,omitempty"` } +// Ended says a step's state is an end: passed, failed or refused. A step running, or ended on a transport fault with runs +// left (StepAgain), is not. +func Ended(s Step) bool { return s.State != StepRunning && s.State != StepAgain && s.State != "" } + var ( nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) stepIDOK = regexp.MustCompile(`^[a-z0-9]+$`) @@ -106,9 +119,9 @@ func (s Step) check() error { return fmt.Errorf("buildstep: a step needs its tool") } switch s.State { - case StepRunning, StepPassed, StepFailed, StepRefused: + case StepRunning, StepAgain, StepPassed, StepFailed, StepRefused: default: - return fmt.Errorf("buildstep: the step state %q is not running, passed, failed or refused", s.State) + return fmt.Errorf("buildstep: the step state %q is not running, again, passed, failed or refused", s.State) } if len(s.Result) > 0 && !json.Valid(s.Result) { return fmt.Errorf("buildstep: the step's result is not JSON")