From e282e089171c21ee6fda06c1c448874bcd7122e8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:28:02 +0100 Subject: [PATCH] =?UTF-8?q?Node-engine:=20a=20held=20own=20composition=20t?= =?UTF-8?q?hat=20was=20handed=20stays=20handed,=20as=20a=20held=20module's?= =?UTF-8?q?=20does=20(ADR=200636=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/selfcompose/selfcompose.go | 7 ++- internal/selfcompose/selfcompose_test.go | 61 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index e806827..5519e1d 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -1029,8 +1029,11 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { out = append(out, agent.Update{Key: key, Value: raw, Revision: c.rev}) } for key := range c.handed { - if _, module, _, _, err := engcomp.ParseKey(key); err == nil && module != "" { - if _, isHeld := held[module]; isHeld { + if _, module, _, isNode, err := engcomp.ParseKey(key); err == nil { + if isNode { + module = agent.NodeModule // the node's own composition is held under its scope + } + if _, isHeld := held[module]; isHeld && module != "" { continue // held: what was handed of it stays, so the node keeps running it } } diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 750f836..c1f1e5c 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -20,6 +20,7 @@ import ( "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" + "maps" "os" "reflect" "slices" @@ -1851,6 +1852,66 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { } } +func TestAHeldOwnCompositionThatWasHandedIsNotDropped(t *testing.T) { + b := newFakeBus() + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) + digest := meshcatalogclient.ManifestDigest([]byte(lemurs)) + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs}) + key, _ := meshcatalogclient.ManifestsKey(digest) + b.kv[buckets.Manifests+"/"+key] = man + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + env := b.env() + g := &gate{} + changed := make(chan struct{}, 1) + env.SeatList, env.SeatListChanged = g, changed + passes := make(chan map[string]string, 8) + env.Refused = func(_ string, held map[string]string) { passes <- maps.Clone(held) } + c := New(env) + out, err := c.Watch(ctx, "n1.>") + if err != nil { + t.Fatal(err) + } + place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) + have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) + nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) + b.put(t, buckets.Placement, "placement.n1", place) + b.put(t, buckets.Have, "n1.node", have) + b.put(t, buckets.Modules, "lemurs", mod) + b.put(t, buckets.Wants, "want.n1.lemurs", want) + b.put(t, buckets.Wants, "want.n1.node", nodeWant) + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { + b.send(bucket, agent.Update{Caught: true}) + } + // The list is read: the node's own composition is handed. Then the reader cannot act again (it restarted and has + // not read): the composition is held, and what was handed of it stays, as a held module's does. + g.open.Store(true) + changed <- struct{}{} + for u := next(t, out); !(u.Key == "n1.node" && !u.Deleted); u = next(t, out) { + } + g.open.Store(false) + changed <- struct{}{} + for h := range passes { + if h[agent.NodeModule] == WaitsForTheSeatListOwn { + break + } + } + deadline := time.After(100 * time.Millisecond) + for { + select { + case u := <-out: + if u.Key == "n1.node" && u.Deleted { + t.Fatalf("the held own composition that was handed is deleted, and the node drops what it runs") + } + case <-deadline: + return + } + } +} + // ADR 0636 ยง1, the cycle of the node's own composition: while the list is unread the node's own composition is held, but // a node whose new own composition must start the holder of the seat mesh-catalog composes of it, whatever the list says, // the holder's resources and its account and nothing else, over what the node's own composition ran before; and the rest