host: an unstaged route waiting for its proof keeps the 30 s miss cadence
fastTick's unstaged backoff doubled past ProofMissTTL for a listed route with no live link whose wait each sweep renewed, so the fast set swept it at 64 s, 128 s, up to 5 minutes, against spec §4.5's 30 s. When the sweep renewed the host's wait, its step is capped at ProofMissTTL. The stale-wait backoff is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+67
−0
internal/host/agent.go+23 −0internal/host/proofask_test.go+44 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index c4719edd7..cf1d9a6a2 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -1628,6 +1628,21 @@ func (d *fastDeps) noteUnstaged(host string, now time.Time) { |
| d.notBefore[host] = now.Add(s) |
| } |
| +// proofWaitRenewed reports whether host is on the proof gate's waiting list |
| +// with a cached miss still running: the sweep just asked the gate about it |
| +// and the gate held it again. |
| +func (d *fastDeps) proofWaitRenewed(host string) bool { |
| + if d.proof.WaitingDue(host) { |
| + return false |
| + } |
| + for _, w := range d.proof.Waits() { |
| + if w.Host == host { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| // backoffFor reports host's current backoff step (zero when none recorded). |
| func (d *fastDeps) backoffFor(host string) time.Duration { return d.step[host] } |
| @@ -1930,6 +1945,14 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) { |
| switch v, err := release.LiveVersion(d.optDir, h); { |
| case err != nil || v == "": |
| d.noteUnstaged(h, now) |
| + // An unstaged route whose wait this sweep renewed is waiting |
| + // for its proof, not failing to stage: spec §4.5 has it rejoin |
| + // the sweep each time its 30 s miss runs out, so its backoff |
| + // stops doubling there. |
| + if d.proofWaitRenewed(h) && d.step[h] > release.ProofMissTTL { |
| + d.step[h] = release.ProofMissTTL |
| + d.notBefore[h] = now.Add(release.ProofMissTTL) |
| + } |
| case d.proof.WaitingDue(h): |
| // The sweep neither renewed this host's proof wait (which would |
| // cache a fresh 30 s miss) nor cleared it: adoptHost returned |
diff --git a/internal/host/proofask_test.go b/internal/host/proofask_test.go| index d1fa0488b..284d29862 100644 |
| --- a/internal/host/proofask_test.go |
| +++ b/internal/host/proofask_test.go |
| @@ -188,6 +188,50 @@ func TestFastMembersKeepsAWaitingRoute(t *testing.T) { |
| } |
| } |
| +// Spec §4.5 for a route with no live link: a new instance of a listed app |
| +// whose head is unproven waits like a staged one, and rejoins the sweep each |
| +// time its 30 s miss runs out. Its unstaged backoff must not stretch that. |
| +func TestFastMembersKeepsAnUnstagedWaitingRouteAtTheMissCadence(t *testing.T) { |
| + f := newFastFixture(t) |
| + ctx := context.Background() |
| + host := "new.example.com" |
| + f.addRoute(t, host, "edge") |
| + plat := release.AdoptPlatform() |
| + m := release.Manifest{App: "fastapp", Version: "v2", Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}} |
| + if err := release.Ship(ctx, f.store, "bab", m, map[string][]byte{plat: []byte("build two")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.Promote(ctx, f.store, f.priv, "bab", "fastapp", "edge", "v2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + policy := filepath.Join(t.TempDir(), "proof-policy.json") |
| + if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"fastapp","prover":"dev","key":%q}]}`, pubB64)), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + clock := time.Now() |
| + gate := release.NewProofGate(policy, f.dataDir).WithClock(func() time.Time { return clock }) |
| + d := f.deps(nil) |
| + d.passOpts = append(d.passOpts, release.WithProofGate(gate)) |
| + d.proof = gate |
| + for i := 0; i < 8; i++ { |
| + if got := d.fastMembers(clock); len(got) != 1 || got[0] != host { |
| + t.Fatalf("cycle %d: members = %v, want the waiting unstaged route (step %v)", i, got, d.backoffFor(host)) |
| + } |
| + fastTick(ctx, d, clock) |
| + if w := gate.Waits(); len(w) != 1 || w[0].Host != host { |
| + t.Fatalf("cycle %d: waits = %+v, want %s waiting", i, w, host) |
| + } |
| + if v, _ := release.LiveVersion(f.optDir, host); v != "" { |
| + t.Fatalf("cycle %d: live = %q, want nothing while unproven", i, v) |
| + } |
| + clock = clock.Add(release.ProofMissTTL + time.Second) |
| + } |
| +} |
| + |
| // Ruling 5's purpose, seen from the fast loop: a wait the sweep neither |
| // renews nor clears (here the route moved to a channel with no pointer, so |
| // adoptHost returns before the gate) must not keep its host in the sweep |