diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f114d28..2716419 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -16,6 +16,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" "sync" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -189,6 +190,8 @@ func (a *Agent) Run(ctx context.Context) error { compsCaught = true } else if m := a.takeComposition(u); m != "" { dirty[m] = true + // The node's own may wait for this one to read (unreadableModules): looked at again with it. + dirty[NodeModule] = true } } // Nothing is applied until what was there at the start is all in: a module's composition may arrive after its @@ -389,6 +392,24 @@ func (a *Agent) others(except string) []delivery.Composed { return out } +// unreadableModules are the keys of the module compositions in the node's bucket that cannot be read or are refused, of a +// module the assigner has not removed. Called holding a.mu. +func (a *Agent) unreadableModules() []string { + var out []string + for key, c := range a.comps { + _, m, _, isNode, err := delivery.ParseComposedKey(key) + if err != nil || isNode || c.Err == nil { + continue + } + if w := a.wants[m]; w.Present && w.Want.Removed { + continue + } + out = append(out, key) + } + sort.Strings(out) + return out +} + // heldOut is what an apply or a removal holds out: others, and every module whose own composition the node's bucket holds // and the agent does not run yet. A module that left the node's own composition for one of its own keeps its resources // until that one is applied, so the node's own apply never removes them in between (issue 1945, live on 2026-10-25: @@ -502,6 +523,16 @@ func (a *Agent) settleOne(ctx context.Context, module string) { a.mu.Unlock() return // held for a failed backup, and no answer since } + if module == NodeModule { + // Not while a module's composition cannot be read or is refused: what it carries may be what left the node's own, + // and the node's own apply would remove it, held out by nothing (issue 1945, the review's gap 2). + if bad := a.unreadableModules(); len(bad) > 0 { + a.sayOnce("node-waits:"+strings.Join(bad, ","), fmt.Sprintf("the node's own composition is not applied while "+ + "%s cannot be read or is refused: what it carries would be removed", strings.Join(bad, ", "))) + a.mu.Unlock() + return + } + } others := a.heldOut(module) a.mu.Unlock() a.apply(ctx, module, w, comp, others) diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index e01214a..31b6b0a 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -111,6 +111,7 @@ type bed struct { backup func(module string) Backup kept map[string][]json.RawMessage others [][]delivery.Composed + said []string env Env } @@ -148,7 +149,12 @@ func newBed(t *testing.T) *bed { }, Kept: func() map[string][]json.RawMessage { return b.kept }, Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub, operatorPub} }, - Say: func(s string) { t.Log(s) }, + Say: func(s string) { + t.Log(s) + b.mu.Lock() + b.said = append(b.said, s) + b.mu.Unlock() + }, } return b } @@ -718,3 +724,39 @@ func TestARemovedModulesCompositionHoldsNothingBack(t *testing.T) { } } } + +// The node's own composition is not applied while a module's composition in the node's bucket cannot be read or is not +// signed by a trusted key: what that one carries may be what left the node's own, and the node's own apply would remove +// it (issue 1945, the review's gap 2). It is said, naming the composition, and applied once that one reads. +func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) { + b := newBed(t) + bad, err := delivery.ComposedKey(node, "broken", fpOf("c")) + if err != nil { + t.Fatal(err) + } + b.comps.before(bad, []byte(`{"v":1,"not":"a composition"}`)) + nk, nraw, ndig := composition(t, NodeModule, "", false) + nwk, _ := delivery.WantNodeKey(node) + nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig}) + b.comps.before(nk, nraw) + b.wants.before(nwk, nwraw) + stop := b.run() + defer stop() + eventually(t, "the wait not said", func() bool { + b.mu.Lock() + defer b.mu.Unlock() + for _, s := range b.said { + if strings.Contains(s, bad) { + return true + } + } + return false + }) + if got := b.appliedList(); len(got) != 0 { + t.Fatalf("applied %v while a composition could not be read", got) + } + // Read again, the node's own is applied. + gk, graw, _ := composition(t, "broken", fpOf("c"), false, file("broken", "x", "1")) + b.comps.put(gk, graw) + eventually(t, "the node's own not applied", func() bool { return len(b.appliedList()) == 1 }) +}