diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 7a3551e..f8da072 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -7,6 +7,7 @@ import ( "crypto/ed25519" "encoding/json" "errors" + "fmt" "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" @@ -20,6 +21,7 @@ import ( engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" + "sort" "strings" "sync" "sync/atomic" @@ -72,7 +74,10 @@ type itBed struct { seatSilent atomic.Bool // seatAsked counts the node's asks of the stand-in seat, answered or refused: the node reached the bus and asked. seatAsked atomic.Int32 - findings nats.KeyValue + // seatAway makes the stand-in answer as the bus answers a subject nothing serves (no responders), as while the seat's + // holder re-serves (issue 1966). + seatAway atomic.Bool + findings nats.KeyValue } // aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what @@ -918,6 +923,10 @@ func (b *itBed) standInSeat() { } _ = json.Unmarshal(m.Data, &q) b.seatAsked.Add(1) + if b.seatAway.Load() { + _ = m.RespondMsg(&nats.Msg{Header: nats.Header{"Status": []string{"503"}}}) // the bus's no responders + return + } var result any switch { case b.seatSilent.Load(): @@ -1002,7 +1011,14 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { b.mu.Lock() defer b.mu.Unlock() said := strings.Join(b.said, "\n") - return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && strings.Contains(said, selfcompose.WaitsForTheSeatList) + // web's reason names the bootstrapped seats the node holds (issue 1966). + var boot []string + for _, d := range seatlist.Bootstrapped() { + boot = append(boot, d.Seat) + } + sort.Strings(boot) + return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && + strings.Contains(said, "web: "+fmt.Sprintf(selfcompose.WaitsForTheSeatListHeld, len(boot), strings.Join(boot, ", "))) }) if b.file("web") != "" { t.Fatalf("the node applied web before it read the seat list: %q", b.file("web")) @@ -1047,6 +1063,10 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { // The first read of the seat list ends the wait whether or not it changed what the reader held: Start returns at once, and // the node waits on the reader's Ready before it says the read is over (ADR 0636 §1). +// +// Issue 1966, the live case of ace: the seat's holder re-serves while the node starts, so the node's reader is answered as +// nothing serves the seat (no responders); once the holder serves again the reader reads the list within the bus's retry +// bound, with no event of the seat and no hand act. func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { clock := &fiveMinutes{} was := seatWaitClock @@ -1054,8 +1074,17 @@ func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { t.Cleanup(func() { seatWaitClock = was }) b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) + b.seatAway.Store(true) stop, _ := h8(t, b, false) defer stop() + var st *seatListState + within(t, 5*time.Second, "the node started no reader of the seat list", func() bool { st = seatListNow.Load(); return st != nil }) + asked := b.seatAsked.Load() + within(t, 10*time.Second, "the node's reader did not ask the seat while nothing served it", func() bool { + now := st.reader.Status() + return b.seatAsked.Load() > asked || (!now.Reading && !now.Heard) + }) + b.seatAway.Store(false) within(t, 30*time.Second, "the node did not end its wait at the first read of the seat list", func() bool { st := seatListNow.Load() if st == nil { @@ -1065,4 +1094,7 @@ func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { waiting, _ := st.waiter.Waiting() return st.reader.Status().Heard && !waiting }) + if seatListNow.Load() != st { + t.Error("the list was read by the reader of a run begun since, not by the one that met no responders") + } } 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/link/asktool_nats_test.go b/internal/link/asktool_nats_test.go index 7e266cf..9d22019 100644 --- a/internal/link/asktool_nats_test.go +++ b/internal/link/asktool_nats_test.go @@ -2,10 +2,12 @@ package link import ( "context" + "errors" "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/busretry" "github.com/nats-io/nats.go" ) @@ -37,4 +39,11 @@ func TestNatsAToolCheckIsAskedOnTheLink(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "nothing on this machine answers") { t.Fatalf("a tool nothing serves read as %v", err) } + // The bus's "no responders" is kept beside ErrNotAsked, so a reader that asks again while its request reached no one + // (busretry.Undelivered) sees it (issue 1966: the seat list's first read fell into its holder's re-serve and was never + // asked again). + _, err = (&natsLink{conn: conn, js: js, node: node}).Ask(ctx, ToolSubject("sensors", "nothing_serves_this", node), nil) + if !errors.Is(err, ErrNotAsked) || !errors.Is(err, nats.ErrNoResponders) || !busretry.Undelivered(err) { + t.Fatalf("an ask nothing serves read as %v: want ErrNotAsked and the bus's no responders, undelivered", err) + } } diff --git a/internal/link/handacts.go b/internal/link/handacts.go index cfffffd..186c7ca 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -52,17 +52,19 @@ const ( // ActManifestReads reads the reader capabilities this engine states (novox/hq ADR 0618 §3). ActManifestReads = nodeengine.VerbManifestReads // ActSeatList reads the seat list this engine holds and what it waits for; ActSeatListAgain makes it read the list now - // (novox/hq ADR 0636 §7). The node-engine seat's definition carries both verbs, which answer only the operator and the - // healers. - ActSeatList = "seat-list" - ActSeatListAgain = "seat-list-again" + // (novox/hq ADR 0636 §7). The node-engine seat's definition carries both verbs. + ActSeatList = nodeengine.VerbSeatList + ActSeatListAgain = nodeengine.VerbSeatListAgain ) -// SeatListCallers are who may call the engine's two seat-list verbs: every operator's session and the healers (novox/hq -// ADR 0636 §7, the node-engine seat's definition: the operator and the healers). +// SeatListCallers are who may call the engine's two seat-list verbs (novox/hq ADR 0636 §7; the node-engine seat's +// definition: the operator and the healers): every operator's session and the healers, both verbs alike. A call through +// the mesh MCP server, and nox, is proved only as its machine's tool runner (.node-tools, issue 627), so node-tools +// stands for an operator's session until a session is proved; without it the verbs answered nobody but the healers, and +// no healer calls them (issue 1966). var SeatListCallers = stdio.VerbCallers{ - ActSeatList: {stdio.AnyPerson, "mesh-healers"}, - ActSeatListAgain: {stdio.AnyPerson, "mesh-healers"}, + ActSeatList: {stdio.AnyPerson, "node-tools", "mesh-healers"}, + ActSeatListAgain: {stdio.AnyPerson, "node-tools", "mesh-healers"}, } // The states of an act: asked, done and refused are also the seat's events. diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 54665ae..1fc5de3 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -301,8 +301,12 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`}, {"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"manifest-reads":["form:a","seat:b"],"node":"n1","stated-at":"2026-11-04T10:00:00Z","v":1`}, - // ADR 0636 §7: the seat-list verbs answer every operator's session and the healers, no other module. + // ADR 0636 §7: both seat-list verbs answer every operator's session, proved as a person or, through the mesh MCP + // server, as its machine's tool runner (issue 627, issue 1966), and the healers, no other module. {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.person~jochen", `"held":true`}, + {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.shanks~node-tools", `"held":true`}, + {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.ace~messenger", `not on the holder's caller list for seat-list`}, + {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.shanks~node-tools", `"read":true`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.person~jochen", `"read":true`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~mesh-healers", `"read":true`}, {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~messenger", `not on the holder's caller list for seat-list-again`}, diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index e9f9fba..50fa1a9 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -324,8 +324,10 @@ func (l *natsLink) Ask(ctx context.Context, subject string, body []byte) ([]byte case err == nil: return reply, nil case errors.Is(err, nats.ErrNoResponders): - // The server says nobody subscribes the subject: delivered to no one. - return nil, fmt.Errorf("%w: nothing on this machine answers %s", ErrNotAsked, subject) + // The server says nobody subscribes the subject: delivered to no one. The bus's own error stays beside ErrNotAsked, + // so a caller that asks again while a request reached no one (mesh-sdk busretry.Undelivered, as the seat list's + // reader does while its holder re-serves; issue 1966) sees it. + return nil, fmt.Errorf("%w: nothing on this machine answers %s (%w)", ErrNotAsked, subject, err) } if refused := l.refusal(subject, before); refused != "" { return nil, fmt.Errorf("%s: %v%s", subject, err, refused) 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") {