diff --git a/internal/link/have.go b/internal/link/have.go index caa01c1..35ec1c0 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -36,7 +36,10 @@ type haveState struct { mu sync.Mutex modules map[string]ModuleFact health map[string]delivery.Health - node *delivery.HaveNode + // looked says a look's health was taken: a running module it judges no resource of then has nothing that could be + // down, and is ok (issue 1945). + looked bool + node *delivery.HaveNode // nodeComposed is the digest of the node's own composition it runs (ADR 0539 ยง9), kept beside the node's facts. nodeComposed string // written is the build of each module this node-engine wrote a have key for and that the declaration it applied @@ -110,7 +113,7 @@ func (q *Session) noteApply(r Report) bool { s.mu.Lock() defer s.mu.Unlock() if r.Health != nil { - s.health = moduleHealths(r.Health) + s.health, s.looked = moduleHealths(r.Health), true } n := nodeFacts(r) s.node = &n @@ -152,7 +155,7 @@ func (q *Session) noteApply(r Report) bool { } s.modules = next if r.Health != nil { - s.health = moduleHealths(r.Health) + s.health, s.looked = moduleHealths(r.Health), true } n := nodeFacts(r) s.node = &n @@ -166,20 +169,20 @@ func (q *Session) noteHealth(h Health) bool { defer s.mu.Unlock() next := moduleHealths(&h) changed := false - for module := range s.modules { + for module, f := range s.modules { want, ok := next[module] if !ok { - want = delivery.HealthUnknown + want = nothingJudged(f, true) } have, known := s.health[module] if !known { - have = delivery.HealthUnknown + have = nothingJudged(f, s.looked) } if have != want { changed = true } } - s.health = next + s.health, s.looked = next, true return changed } @@ -232,6 +235,8 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { } if got, known := s.health[module]; known { h.Health = got + } else { + h.Health = nothingJudged(f, s.looked) } key, err := delivery.HaveKey(node, module) if err != nil { @@ -452,3 +457,12 @@ func (q *Session) ReadHave(ctx context.Context, module string) (delivery.Have, b } return h, true } + +// nothingJudged is the health of a module a look judged no resource of: ok when it was looked at and the module applied +// and runs (nothing of it could be down), unknown otherwise. +func nothingJudged(f ModuleFact, looked bool) delivery.Health { + if looked && !f.Failed && (f.Phase == "" || f.Phase == string(delivery.PhaseRunning)) { + return delivery.HealthOK + } + return delivery.HealthUnknown +} diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 5402d54..a16b840 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -498,3 +498,26 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( t.Fatalf("the node key: %+v %v", n, err) } } + +// A module applied and running that has nothing long-running to judge is ok once the engine has looked: there is nothing +// of it that could be down. Unknown kept it from ever being healthy, so the assigner's canary rule held every other node +// of mesh-host back for good (issue 1945, live on 2026-10-25: ace's have of mesh-host said unknown, and novox and shanks +// never got the new node-engine). A failed one stays failed, and before any look it is unknown. +func TestAModuleWithNothingToJudgeIsOkOnceLookedAt(t *testing.T) { + l := newHaveLink() + q, _ := aHaveQueue(l) + q.SayFacts(context.Background(), Report{Modules: []ModuleFact{{Module: "mesh-host", Build: "b"}, {Module: "web", Build: "b"}, + {Module: "broken", Build: "b", Failed: true, Detail: "x"}}}) + if h := l.have(t, "n1.mesh-host").Health; h != delivery.HealthUnknown { + t.Fatalf("before any look: %s", h) + } + if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) { + t.Fatal("not said") + } + if h := l.have(t, "n1.mesh-host").Health; h != delivery.HealthOK { + t.Fatalf("a running module with nothing to judge after a look: %s", h) + } + if h := l.have(t, "n1.broken").Health; h == delivery.HealthOK { + t.Fatalf("a failed module was said ok") + } +}