Cap the forge wait as a whole, and wait out only a forge not served (novox/hq issue 486)
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

The registration holds the controller's one receive loop. Checking the bound
only between asks let an ask in flight hold it near two minutes, the
deaf-controller limit. The whole wait now runs under a 60 s deadline, still
says it waited when that deadline ends it, and a tool that did not answer in
time is not asked again: it has already held the loop its whole wait.
This commit is contained in:
2026-10-12 11:32:14 +02:00
parent 47205403d3
commit 4aacd98f8d
2 changed files with 77 additions and 36 deletions
+37 -21
View File
@@ -172,10 +172,10 @@ type waitOut struct {
// forgeWait is the registration's wait for the forge (novox/hq issue 486). On 2026-10-11 a build's registration
// asked the forge while node-tools on the control-node re-served after a rollout of the tool runner, heard
// "nothing serves gitea.gitea_api", and failed its plan; the forge answered again seconds later. A re-serve takes
// seconds, so 90 s waits one out with room to spare, and stays short because the registration holds the
// controller's receive loop while it waits. The pauses are those the build machine waits the registry with
// (novox/hq issue 457).
var forgeWait = waitOut{bound: 90 * time.Second, first: time.Second, most: 10 * time.Second,
// about 10 s, so 60 s waits one out with room to spare. It is a cap on the whole wait, an ask in flight included:
// the registration holds the controller's one receive loop while it waits, and a loop held two minutes reads as
// deaf. The pauses are those the build machine waits the registry with (novox/hq issue 457).
var forgeWait = waitOut{bound: 60 * time.Second, first: time.Second, most: 10 * time.Second,
now: time.Now, sleep: sleepOrDone}
// sleepOrDone pauses for d, or less when ctx ends first.
@@ -190,37 +190,53 @@ func sleepOrDone(ctx context.Context, d time.Duration) error {
}
}
// forgeNotNow is an answer that says the forge's tool is not there now, not that the forge refused: nothing
// serves it (the tool runner re-serving), or it did not answer within the wait. Anything else — a refusal, an
// unreadable answer, a repository with no id — is the forge's word, and is never asked again.
func forgeNotNow(err error) bool {
return errors.Is(err, link.ErrNothingServes) || errors.Is(err, link.ErrNoAnswer)
}
// forgeNotNow is an answer that says the forge's tool is not served now — the tool runner re-serving — not that
// the forge refused. Only that is asked again: a refusal, an unreadable answer or a repository with no id is the
// forge's word, and a tool that did not answer in time has already held the receive loop its whole wait.
func forgeNotNow(err error) bool { return errors.Is(err, link.ErrNothingServes) }
// forgeWaitedOut asks the forge about repository, asking again within w's bound while it answers "not now"
// (novox/hq issue 486). Past the bound it fails, saying it waited, for how long, and what the forge last said.
// forgeWaitedOut asks the forge about repository, asking again while it answers "not served now", all of it
// within w's bound, an ask in flight included (novox/hq issue 486). Past the bound it fails, saying it waited,
// for how long, and what the forge last said; when the caller's own context ends, it says that instead.
func forgeWaitedOut(ctx context.Context, w waitOut, repository string,
ask func(context.Context) (forgeFacts, error)) (forgeFacts, error) {
capped, cancel := context.WithTimeout(ctx, w.bound)
defer cancel()
began := w.now()
pause := w.first
for asked := 1; ; asked++ {
facts, err := ask(ctx)
if err == nil || !forgeNotNow(err) || ctx.Err() != nil {
if err == nil && asked > 1 {
facts, err := ask(capped)
if err == nil {
if asked > 1 {
fmt.Printf("the forge answered about %s again, after %s (novox/hq issue 486)\n", repository,
w.now().Sub(began).Round(time.Second))
}
return facts, err
return facts, nil
}
waited := w.now().Sub(began)
if waited+pause > w.bound {
if ctx.Err() != nil {
return forgeFacts{}, err // the caller's own context ended: no wait of ours to speak of
}
gaveUp := func() (forgeFacts, error) {
return forgeFacts{}, fmt.Errorf("waited %s for the forge to answer about %s, asking %d times, and it "+
"did not (novox/hq issue 486): %w", waited.Round(time.Second), repository, asked, err)
"did not (novox/hq issue 486): %w", w.now().Sub(began).Round(time.Second), repository, asked, err)
}
if capped.Err() != nil {
return gaveUp() // the bound ended an ask in flight
}
if !forgeNotNow(err) {
return forgeFacts{}, err
}
if w.now().Sub(began)+pause > w.bound {
return gaveUp()
}
fmt.Printf("the forge cannot be asked about %s now (%v); asking again in %s, for at most %s in all "+
"(novox/hq issue 486)\n", repository, err, pause, w.bound)
if err := w.sleep(ctx, pause); err != nil {
return forgeFacts{}, fmt.Errorf("stopped waiting for the forge to answer about %s: %w", repository, err)
if serr := w.sleep(capped, pause); serr != nil {
if ctx.Err() != nil {
return forgeFacts{}, fmt.Errorf("stopped waiting for the forge to answer about %s: %w", repository,
ctx.Err())
}
return gaveUp()
}
pause = min(2*pause, w.most)
}
+40 -15
View File
@@ -32,7 +32,7 @@ func notServed() error {
// A registration's forge question that lands while the tool runner re-serves is asked again, and the forge's
// answer, seconds later, is the registration's (novox/hq issue 486).
func TestTheForgeNotServedForAMomentIsWaitedOut(t *testing.T) {
w, paused := aFakeWait(90 * time.Second)
w, paused := aFakeWait(60 * time.Second)
asked := 0
facts, err := forgeWaitedOut(t.Context(), w, "novox/mesh-catalog", func(context.Context) (forgeFacts, error) {
asked++
@@ -52,25 +52,50 @@ func TestTheForgeNotServedForAMomentIsWaitedOut(t *testing.T) {
}
}
// A tool that did not answer within its wait is waited out the same way: nothing refused.
func TestTheForgeNotAnsweringInTimeIsAskedAgain(t *testing.T) {
w, _ := aFakeWait(90 * time.Second)
// A tool that did not answer within its wait has held the receive loop that long already: not asked again.
func TestTheForgeNotAnsweringInTimeIsNotAskedAgain(t *testing.T) {
w, paused := aFakeWait(60 * time.Second)
asked := 0
_, err := forgeWaitedOut(t.Context(), w, "novox/x", func(context.Context) (forgeFacts, error) {
asked++
if asked == 1 {
return forgeFacts{}, fmt.Errorf("gitea did not answer: %w", link.ErrNoAnswer)
}
return forgeFacts{ID: 1, Guarded: true}, nil
return forgeFacts{}, fmt.Errorf("gitea did not answer: %w", link.ErrNoAnswer)
})
if err != nil || asked != 2 {
t.Fatalf("asked %d times: %v", asked, err)
if !errors.Is(err, link.ErrNoAnswer) || asked != 1 || len(*paused) != 0 {
t.Fatalf("a forge that did not answer in time was asked %d times, paused %v: %v", asked, *paused, err)
}
}
// An ask that hangs past the bound is cut off at the bound, not at its own wait, and the failure still says it
// waited: the receive loop is held no longer than the bound (novox/hq issue 486).
func TestAnAskHangingPastTheBoundIsCutOffAtIt(t *testing.T) {
w := waitOut{bound: 200 * time.Millisecond, first: 10 * time.Millisecond, most: 50 * time.Millisecond,
now: time.Now, sleep: sleepOrDone}
asked := 0
began := time.Now()
_, err := forgeWaitedOut(t.Context(), w, "novox/mesh-catalog", func(ctx context.Context) (forgeFacts, error) {
asked++
if asked == 1 {
return forgeFacts{}, notServed()
}
select { // link.Ask's own wait is 30 s; the bound must end it first
case <-ctx.Done():
return forgeFacts{}, fmt.Errorf("gitea did not answer: %w", link.ErrNoAnswer)
case <-time.After(30 * time.Second):
return forgeFacts{ID: 1, Guarded: true}, nil
}
})
if took := time.Since(began); took > 2*time.Second {
t.Fatalf("the wait took %s, past its bound of %s", took, w.bound)
}
if err == nil || !strings.Contains(err.Error(), "waited") || !strings.Contains(err.Error(), "asking 2 times") ||
!strings.Contains(err.Error(), "issue 486") {
t.Fatalf("the bound's end does not say it waited: %v", err)
}
}
// Past the bound the registration fails, saying it waited, for how long, and what the forge last said.
func TestTheForgeNotServedPastTheBoundFailsSayingItWaited(t *testing.T) {
w, paused := aFakeWait(90 * time.Second)
w, paused := aFakeWait(60 * time.Second)
_, err := forgeWaitedOut(t.Context(), w, "novox/mesh-catalog", func(context.Context) (forgeFacts, error) {
return forgeFacts{}, notServed()
})
@@ -81,8 +106,8 @@ func TestTheForgeNotServedPastTheBoundFailsSayingItWaited(t *testing.T) {
for _, p := range *paused {
waited += p
}
if waited > 90*time.Second || waited < 60*time.Second {
t.Fatalf("waited %s in all, want close to and within the bound of 90s", waited)
if waited > 60*time.Second || waited < 40*time.Second {
t.Fatalf("waited %s in all, want close to and within the bound of 60s", waited)
}
for _, want := range []string{"waited " + waited.String(), "novox/mesh-catalog", "nothing serves", "issue 486"} {
if !strings.Contains(err.Error(), want) {
@@ -96,7 +121,7 @@ func TestTheForgeNotServedPastTheBoundFailsSayingItWaited(t *testing.T) {
// A refusal is the forge's word: never asked again.
func TestARefusalFromTheForgeIsNeverAskedAgain(t *testing.T) {
w, paused := aFakeWait(90 * time.Second)
w, paused := aFakeWait(60 * time.Second)
asked := 0
_, err := forgeWaitedOut(t.Context(), w, "novox/x", func(context.Context) (forgeFacts, error) {
asked++
@@ -109,7 +134,7 @@ func TestARefusalFromTheForgeIsNeverAskedAgain(t *testing.T) {
// A registration whose own context ends stops waiting at once.
func TestAWaitForTheForgeEndsWithItsContext(t *testing.T) {
w, _ := aFakeWait(90 * time.Second)
w, _ := aFakeWait(60 * time.Second)
ctx, cancel := context.WithCancel(t.Context())
w.sleep = func(ctx context.Context, d time.Duration) error { cancel(); return sleepOrDone(ctx, d) }
asked := 0