From 4af6c274b8104684037c95af9856b6a767a8a196 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 00:40:53 +0100 Subject: [PATCH] S4: an unread seat list holds a module before its build is read On ace, a node-engine left on its bootstrapped seat list could not find the artifact store's seat, refused avahi's archive reference and with it the whole pass, so the seat-list hold never ran and every module said "reaches no artifact store". While the list is unread, a placed module whose seats are not all bootstrapped is now held as it runs before anything of its build is read (frombus.Scope.Held), its reason names the bootstrapped seats the node holds, and a pass refused whole says the unread list first (issue 1966). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- internal/compose/frombus/frombus.go | 13 ++++- internal/selfcompose/selfcompose.go | 62 ++++++++++++++++++++-- internal/selfcompose/selfcompose_test.go | 67 +++++++++++++++++++++--- 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 069e370..89e6e93 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -182,6 +182,10 @@ type Scope struct { // decoder (compose.Input.Holds), with the build it would compose it at, so the node says why it holds it (issue 1921, // the composition verb). Nil: said nowhere. Holds func(map[string]compose.Hold) + // Held is the modules the node holds before anything of their builds is read, each with why (a node that has not read + // the seat list, novox/hq ADR 0636 §1): each is held as it runs, as a module refused alone is, so no take-in or + // artifact reference of it can refuse the node (issue 1966). Nil holds none. + Held map[string]string } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -585,7 +589,10 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte // node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served // elsewhere, a stand-in for the node's own) refuses the node, err. func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]engcomp.Composed, skipped []string, held map[string]string, err error) { - held = map[string]string{} + held = maps.Clone(s.Held) + if held == nil { + held = map[string]string{} + } // A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774, // 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid). defer func() { @@ -933,6 +940,10 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m } } for _, pm := range p.Modules { + if why, held := s.Held[pm.Module]; held && refuse != nil { + stub(pm.Module, errors.New(why)) // nothing of its build is read: it keeps what runs (Scope.Held) + continue + } m, ok := modules[pm.Module] if (!ok || m.Fp2 == "") && s.Unmade != nil && wanted[pm.Module] != "" { s.Unmade(pm.Module, "the indexer's modules hold no build of the new form for it (fingerprint "+m.Fp2+"), so it is composed from the manifest") diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index d07bdfa..3810cab 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -43,6 +43,7 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/resolution" @@ -997,6 +998,10 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { c.mu.Lock() c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed c.mu.Unlock() + // A node that has not read the seat list takes nothing of a module whose seats are not all bootstrapped from its build + // (ADR 0636 §1): the module is held as it runs before anything of its build is read, so no take-in or artifact + // reference of it (the artifact store's seat is not bootstrapped) can refuse the pass (issue 1966). + scope.Held = c.heldUntilListed(ctx, r, own) missed := c.missed.Load() compositions, skipped, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps) err = c.missedIn(missed, err) @@ -1021,6 +1026,10 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } if err != nil { why := fmt.Sprintf("%v", err) + if line, unread := c.seatListUnread(); unread { + // A refusal while the list is unread may be of a seat the node cannot yet know: said first (issue 1966). + why = line + "; " + why + } // A bus that did not answer past every try is no answer: composed again after a bound (issue 1126, 2026-10-28), // and the refusal says when (issue 774, 2026-10-31). A refusal is final until an input moves. if BusFailed(err) { @@ -1869,8 +1878,54 @@ func mapValues(m map[string]compositions.Composed) []compositions.Composed { return out } -// WaitsForTheSeatList is why a module is held while the node has not read the seat list. -const WaitsForTheSeatList = "the node has not read the seat list, so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" +// WaitsForTheSeatList is why a module is held while the node has not read the seat list, said by a gate that names no +// seat it holds. +const WaitsForTheSeatList = "the node has not read the seat list" + composesNothingNewOfIt + +// WaitsForTheSeatListHeld is why a module is held while the node holds only its bootstrapped seat list (novox/hq ADR 0636 +// §2): the number and the names of the seats it holds, then what it does of the module (issue 1966). +const WaitsForTheSeatListHeld = "the node holds only its bootstrapped seat list (%d seats: %s) and has not read the seat " + + seatlist.Seat + +const composesNothingNewOfIt = ", so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" + +// seatListUnread is, while the node has not read the seat list, the line that says so, naming the bootstrapped seats it +// holds when its gate says them (the reader's Status); false once the list was read, or with no gate. +func (c *Composer) seatListUnread() (string, bool) { + if c.env.SeatList == nil || c.env.SeatList.MayAct() { + return "", false + } + says, ok := c.env.SeatList.(interface{ Status() seatlist.Status }) + if !ok { + return strings.TrimSuffix(WaitsForTheSeatList, composesNothingNewOfIt), true + } + var seats []string + for _, h := range says.Status().Held { + seats = append(seats, h.Seat) + } + return fmt.Sprintf(WaitsForTheSeatListHeld, len(seats), strings.Join(seats, ", ")), true +} + +// heldUntilListed is, while the node has not read the seat list, every module placed on it whose seats are not all +// bootstrapped, each with why: the pass holds them as they run before reading anything of their builds (frombus.Scope.Held). +// A holder of the seat mesh-catalog is composed whatever the list says, since the list is read from it (holdUntilListed, +// holderPart). Nil once the list was read. +func (c *Composer) heldUntilListed(ctx context.Context, r reader, own meshmoduleassigner.Assignments) map[string]string { + line, unread := c.seatListUnread() + if !unread { + return nil + } + holders := c.claimants(ctx, r, meshcatalog.Seat) + held := map[string]string{} + for _, pm := range own.Modules { + if pm.Module == "" || pm.Module == agent.NodeModule || slices.Contains(holders, pm.Module) || + c.env.SeatList.MayAct(c.seatsOf(ctx, r, pm.Module)...) { + continue + } + held[pm.Module] = line + composesNothingNewOfIt + } + return held +} // WaitsForTheSeatListOwn is why the node's own composition is held: it folds modules and accounts whose seats the node // cannot judge before the list was read, so only the last applied one runs (ADR 0636 §1). @@ -1906,7 +1961,8 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m if dig, err := engcomp.DigestOf(comp); err == nil && ran[comp.Module+"/"+dig] { continue } - module, why := comp.Module, WaitsForTheSeatList + line, _ := c.seatListUnread() + module, why := comp.Module, line+composesNothingNewOfIt if module == "" { // the node's own: no seat to judge, so while the list is unread it waits whole if c.env.SeatList.MayAct() { continue diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 277f4e2..f6d1f38 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -32,6 +32,8 @@ import ( "time" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" @@ -1842,6 +1844,20 @@ type gate struct{ open atomic.Bool } func (g *gate) MayAct(...string) bool { return g.open.Load() } +// namedGate is a gate that says the seats it holds, as the reader of the seat list does (seatlist.Reader.Status). +type namedGate struct { + *gate + seats []string +} + +func (g *namedGate) Status() seatlist.Status { + st := seatlist.Status{Heard: g.open.Load()} + for _, s := range g.seats { + st.Held = append(st.Held, seatlist.Held{Seat: s, Source: seatlist.SourceBootstrap}) + } + return st +} + // ADR 0636 §1: a node that has not read the seat list composes nothing new of a module whose seats are not bootstrapped, // says it holds the module (so its have says why) and hands it at the read, which the reader says as an event; what // already runs with the same composition is not held. @@ -1852,22 +1868,38 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs}) key, _ := meshcatalogclient.ManifestsKey(digest) b.kv[buckets.Manifests+"/"+key] = man + // Issue 1966: avahi's archive is in the artifact store, whose seat the bootstrap does not hold, so before the read the + // node reaches no store; avahi is held for the list before its build is read, and the pass is not refused for it. + avahi := `{"module":"avahi","version":"1","capabilities":["systemd"],"resources":[{"id":"tools","type":"archive",` + + `"path":"/opt/avahi","source":"` + catalogue.ArtifactStoreScheme + `avahi/tools/blobs/sha256:` + strings.Repeat("d", 64) + `"}]}` + avahiDigest := meshcatalogclient.ManifestDigest([]byte(avahi)) + avahiMan, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "avahi", Manifest: avahi}) + avahiKey, _ := meshcatalogclient.ManifestsKey(avahiDigest) + b.kv[buckets.Manifests+"/"+avahiKey] = avahiMan ctx, cancel := context.WithCancel(context.Background()) defer cancel() env := b.env() - g := &gate{} + g := &namedGate{gate: &gate{}, seats: []string{"mesh-catalog", "node-engine"}} changed := make(chan struct{}, 1) env.SeatList, env.SeatListChanged = g, changed var mu sync.Mutex heldBy := map[string]string{} - env.Refused = func(_ string, held map[string]string) { mu.Lock(); heldBy = held; mu.Unlock() } + var refusedWhole []string + env.Refused = func(why string, held map[string]string) { + mu.Lock() + defer mu.Unlock() + heldBy = held + if why != "" { + refusedWhole = append(refusedWhole, why) + } + } c := New(env) out, err := c.Watch(ctx, "n1.>") if err != nil { t.Fatal(err) } place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "avahi"}}}) 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}) @@ -1875,6 +1907,8 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) b.put(t, buckets.Modules, "lemurs", mod) + avahiMod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: avahiDigest, Repository: "novox/x", Commit: "c"}) + b.put(t, buckets.Modules, "avahi", avahiMod) 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.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { @@ -1888,11 +1922,32 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { } handed[u.Key] = true } + // The reason names the bootstrapped list the node holds (issue 1966: ace's modules said "reaches no artifact store"). + waits := fmt.Sprintf(WaitsForTheSeatListHeld, 2, "mesh-catalog, node-engine") + composesNothingNewOfIt mu.Lock() - why := heldBy["lemurs"] + why, avahiWhy, whole := heldBy["lemurs"], heldBy["avahi"], slices.Clone(refusedWhole) mu.Unlock() - if why != WaitsForTheSeatList { - t.Errorf("lemurs is held with %q, want %q", why, WaitsForTheSeatList) + if why != waits || avahiWhy != waits || len(whole) != 0 { + t.Errorf("lemurs is held with %q, avahi with %q, the pass refused %q; want both %q and no refusal", why, avahiWhy, whole, waits) + } + // A pass refused whole while the list is unread says the list first (issue 1966). + refusing := b.env() + refusing.SeatList = g + refusing.Get = func(context.Context, string, string) ([]byte, error) { + return nil, errors.New("nats says ${dir:mesh-state}, and nats declares no directory") + } + var refusal string + refusing.Refused = func(w string, _ map[string]string) { refusal = w } + c2 := New(refusing) + c.mu.Lock() + maps.Copy(c2.wants, c.wants) + maps.Copy(c2.assignments, c.assignments) + maps.Copy(c2.records, c.records) + maps.Copy(c2.modules, c.modules) + c.mu.Unlock() + c2.compose(ctx) + if line := fmt.Sprintf(WaitsForTheSeatListHeld, 2, "mesh-catalog, node-engine") + "; "; !strings.HasPrefix(refusal, line) { + t.Errorf("a pass refused while the list is unread says %q, want it to begin %q", refusal, line) } for k := range handed { if strings.Contains(k, "lemurs") {