From 1d0df24a0dabd56fd746f5f763c9236e9b59e044 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 23:10:48 +0000 Subject: [PATCH 1/3] Carry a module on the tool runner only when the runner has something of it to serve, so a part on its own credential has one writer (issue 1329) --- internal/broker/delivery_gate_grant_test.go | 4 +- internal/broker/delivery_parts_grant_test.go | 152 ++++++++++++++++++- internal/broker/modstreams_test.go | 2 +- internal/broker/users.go | 13 +- 4 files changed, 165 insertions(+), 6 deletions(-) diff --git a/internal/broker/delivery_gate_grant_test.go b/internal/broker/delivery_gate_grant_test.go index 0a1db088..8466d4cf 100644 --- a/internal/broker/delivery_gate_grant_test.go +++ b/internal/broker/delivery_gate_grant_test.go @@ -67,8 +67,8 @@ func TestTheGatesBucketsAreWritableByItsMachinesToolRunner(t *testing.T) { if !canRead(runner, read, "k") { t.Errorf("novox's tool runner may not read %s, which the gate declares reading", read) } - // (The runner may write the teller's answers: the teller declares no runs-as, so the runner carries it too. See the report of issue 1326.) - if canWrite(runner, read, "k") && read != "mesh-delivery-teller_answers" { + // 1329/AC3: the runner no longer carries the teller, so it may not write the teller's answers either. + if canWrite(runner, read, "k") { t.Errorf("novox's tool runner may write %s, which the gate only reads", read) } } diff --git a/internal/broker/delivery_parts_grant_test.go b/internal/broker/delivery_parts_grant_test.go index 836a9f9a..947e69c1 100644 --- a/internal/broker/delivery_parts_grant_test.go +++ b/internal/broker/delivery_parts_grant_test.go @@ -13,8 +13,9 @@ import ( // and streams, `reads` watching another module's bucket and never writing it, `consumes` hearing the named events, // `invokes` calling the named tools, `uses` the seat operator-channel's by-caller subjects under the module's own name. // -// The three manifests are copied from mesh-delivery's main at 6a23627426c6 (modules//module.json, read -// 2026-10-19), whole, so a change to one of them there that changes its grant is a change to read here. +// The four manifests (the watchman's is the fourth, issue 1329) are copied from mesh-delivery's main at 9c15112d935a +// (modules//module.json, read 2026-10-20; the first three are unchanged since 6a23627426c6), whole, so a change +// to one of them there that changes its grant is a change to read here. const bridgeManifest = `{ "module": "mesh-delivery-forge-bridge", "version": "1", @@ -160,6 +161,69 @@ const indexerManifest = `{ } }` +const watchmanManifest = `{ + "module": "mesh-delivery-watchman", + "version": "1", + "slug": "watchman", + "reads": [ + "mesh-controller.checks-running" + ], + "state": [ + { "name": "presence", "history": 20 }, + { "name": "pulse", "key-ttl": true }, + { "name": "stuck", "history": 20 } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-watchman", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-watchman", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-watchman" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-watchman", + "place": ".", + "unknown": "program" + }, + { + "id": "watchman", + "type": "process", + "name": "mesh-delivery-watchman", + "artifact": "program", + "run": [ + "./mesh-delivery-watchman" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-watchman", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-watchman", + "binary": "mesh-delivery-watchman" + } + ] + } +}` + const tellerManifest = `{ "module": "mesh-delivery-teller", "version": "1", @@ -488,3 +552,87 @@ func TestNoPartsCredentialReachesBeyondItsDeclaration(t *testing.T) { } } } + +// toolServer is a module that serves a tool on its own broker credential: the runner carries it (ADR 0175). +func toolServer() Declared { + return Declared{Module: "some-tools", Serves: []string{"some_tool"}, + State: []Bucket{{Module: "some-tools", Name: "kept", History: 1}}} +} + +// 1329/AC1: composed from the manifests of mesh-delivery's main, the machine's tool runner may write no bucket of the +// forge bridge, the indexer, the teller or the watchman, publish none of their stream events nor the teller's +// operator-channel accept subjects, and pull none of their consumers; each part's own credential still may. +func TestTheToolRunnerCarriesNoneOfTheDeliverysParts(t *testing.T) { + streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}} + all := composed(t, Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: RuntimeModule}, declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams), + declaredOf(t, tellerManifest, streams), declaredOf(t, watchmanManifest, streams), + }}}) + runner := all["runner@novox"] + buckets := map[string][]string{ + "mesh-delivery-forge-bridge": {"heads"}, "mesh-delivery-indexer": {"candidates", "modules"}, + "mesh-delivery-teller": {"answers"}, "mesh-delivery-watchman": {"presence", "pulse", "stuck"}, + } + for part, names := range buckets { + own, there := all[part+"@novox"] + if !there { + t.Errorf("%s has no credential of its own", part) + continue + } + for _, name := range names { + if canWrite(runner, part+"_"+name, "some.key") { + t.Errorf("novox's tool runner may write %s_%s", part, name) + } + if !canWrite(own, part+"_"+name, "some.key") { + t.Errorf("%s may not write its own bucket %s", part, name) + } + } + } + for _, subject := range []string{ + "mesh.stream.mesh-delivery-forge-bridge.event.forge.merged", + "mesh.seat.operator-channel.accept.ask.mesh-delivery-teller", + "mesh.seat.operator-channel.accept.cancel.mesh-delivery-teller", + "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller", + "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-indexer", + } { + if allowed(runner.Publish, subject) { + t.Errorf("novox's tool runner may publish %s", subject) + } + } + if !allowed(all["mesh-delivery-forge-bridge@novox"].Publish, "mesh.stream.mesh-delivery-forge-bridge.event.forge.merged") || + !allowed(all["mesh-delivery-teller@novox"].Publish, "mesh.seat.operator-channel.accept.ask.mesh-delivery-teller") || + !allowed(all["mesh-delivery-indexer@novox"].Publish, "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-indexer") { + t.Error("a part's own credential lost what its manifest grants") + } +} + +// 1329/AC2: a module with tools and a broker secret of its own stays carried by the tool runner, beside its own +// credential (ADR 0175); a module with neither tools, a seat, waits nor a check is not. +func TestTheToolRunnerStillCarriesAModuleWithToolsAndItsOwnCredential(t *testing.T) { + process := Declared{Module: "only-process", State: []Bucket{{Module: "only-process", Name: "mine", History: 1}}} + all := composed(t, Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: RuntimeModule}, toolServer(), process, + }}}) + runner := all["runner@novox"] + if !canWrite(runner, "some-tools_kept", "k") { + t.Error("novox's tool runner does not carry a module with tools") + } + if !canWrite(all["some-tools@novox"], "some-tools_kept", "k") { + t.Error("the module with tools lost its own credential") + } + if canWrite(runner, "only-process_mine", "k") { + t.Error("novox's tool runner carries a module that has nothing to serve") + } + if !canWrite(all["only-process@novox"], "only-process_mine", "k") { + t.Error("the process module lost its own credential") + } + for name, d := range map[string]Declared{ + "a held seat": {Module: "m", Holds: []Seat{{Name: "some-seat"}}}, + "a wait": {Module: "m", Waits: []Wait{{}}}, + "a check": {Module: "m", Checks: []string{"m.check"}}, + } { + if !runnerServes(d) { + t.Errorf("the tool runner does not serve a module with %s", name) + } + } +} diff --git a/internal/broker/modstreams_test.go b/internal/broker/modstreams_test.go index 9445a64d..a29b560f 100644 --- a/internal/broker/modstreams_test.go +++ b/internal/broker/modstreams_test.go @@ -154,7 +154,7 @@ func TestTheToolRunnerNeverHearsARestrictedStream(t *testing.T) { records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{ "novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, {Module: "news", Streams: []ModuleStream{newsStream}}}, "ace": {{Module: RuntimeModule}, - {Module: "carried", Hears: []Heard{mailHeard, newsHeard}, HearsAStream: true}, + {Module: "carried", NoAccount: true, Hears: []Heard{mailHeard, newsHeard}, HearsAStream: true}, {Module: "mailer", RunsAs: "mailer", Hears: []Heard{mailHeard}, HearsAStream: true}}, }} users, err := Users(records) diff --git a/internal/broker/users.go b/internal/broker/users.go index 46c01604..3612dc36 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -206,7 +206,7 @@ func Users(r Records) ([]Principal, error) { if runtimeHere { var carried []Declared for _, d := range r.Assigned[node] { - if d.RunsAs == "" { + if d.RunsAs == "" && runnerServes(d) { // Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1). d.Hears = ComposedHears(d) carried = append(carried, d) @@ -391,3 +391,14 @@ func boundBucketsOf(r Records) []string { sort.Strings(out) return out } + +// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1, +// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on, +// or a check its health asks. A module with none of these is a process of its own on its own credential, and +// carrying it would hand every session on the machine the runner's account with that module's state, seat +// traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it +// stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not +// the test: a module with tools and its own credential is still carried (ADR 0175). +func runnerServes(d Declared) bool { + return d.Module == RuntimeModule || d.NoAccount || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 +} From b893cc829c824d72323f24fa201b4bf8da104c3b Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 23:14:51 +0000 Subject: [PATCH 2/3] Keep carrying a module whose bundle the tool runtime loads, as route-proxy's helper writes its state on the runner's account (issue 1329) --- internal/broker/delivery_parts_grant_test.go | 55 ++++++++++++++++++++ internal/broker/users.go | 8 ++- internal/inventory/busrecords.go | 6 +++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/internal/broker/delivery_parts_grant_test.go b/internal/broker/delivery_parts_grant_test.go index 947e69c1..b4e622ca 100644 --- a/internal/broker/delivery_parts_grant_test.go +++ b/internal/broker/delivery_parts_grant_test.go @@ -636,3 +636,58 @@ func TestTheToolRunnerStillCarriesAModuleWithToolsAndItsOwnCredential(t *testing } } } + +// routeProxyExcerpt is the part of mesh-control's modules/route-proxy/module.json at 70cb217601f9 (read 2026-10-20) +// that decides its grants: its state, what it reads, its own broker secret, and the bundle `provider` that the machine's +// runtime loads (`loads`) though the module declares no tools. +const routeProxyExcerpt = `{ + "module": "route-proxy", + "state": ["bound"], + "reads": ["mesh-controller.assignments"], + "own-secrets": { "broker": "${dir:mesh-state}/broker" }, + "build": { "artifacts": [ + { "name": "provider", "kind": "bundle", "language": "go", "from": "cmd/route-provider", "binary": "route-provider", "loads": ["route-provider"] } + ] } +}` + +// 1329 (review): a module with no tools whose bundle the runtime loads stays carried, because its helper writes its state +// and reads on the runner's account; the delivery's five parts are still dropped. +func TestTheToolRunnerStillCarriesAModuleWhoseBundleItLoads(t *testing.T) { + var m struct { + Module string `json:"module"` + State []string `json:"state"` + Reads []string `json:"reads"` + Build struct { + Artifacts []struct { + Loads []string `json:"loads"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal([]byte(routeProxyExcerpt), &m); err != nil { + t.Fatal(err) + } + proxy := Declared{Module: m.Module, Reads: m.Reads} + for _, s := range m.State { + proxy.State = append(proxy.State, Bucket{Module: m.Module, Name: s, History: 1}) + } + for _, a := range m.Build.Artifacts { + proxy.Loads = proxy.Loads || len(a.Loads) > 0 + } + streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}} + all := composed(t, Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: RuntimeModule}, proxy, declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams), + declaredOf(t, tellerManifest, streams), declaredOf(t, watchmanManifest, streams), + }}}) + runner := all["runner@novox"] + if !canWrite(runner, "route-proxy_bound", "k") { + t.Error("novox's tool runner does not carry route-proxy, whose bundle it loads") + } + if !canWrite(all["route-proxy@novox"], "route-proxy_bound", "k") { + t.Error("route-proxy lost its own credential") + } + for _, b := range []string{"mesh-delivery-teller_answers", "mesh-delivery-forge-bridge_heads", "mesh-delivery-indexer_modules", "mesh-delivery-watchman_stuck"} { + if canWrite(runner, b, "k") { + t.Errorf("novox's tool runner may write %s", b) + } + } +} diff --git a/internal/broker/users.go b/internal/broker/users.go index 3612dc36..c33814db 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -69,6 +69,10 @@ type Declared struct { // Checks are the module's own tools its health asks, each `.` (novox/hq ADR 0240, to-be // 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more. Checks []string + // Loads says the module has a compiled bundle whose entrypoints the machine's tool runtime loads and runs + // (novox/hq ADR 0175; issue 1329): a helper that reaches the bus on the runner's account, so the runner + // must carry the module although it declares no tools. + Loads bool // RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never // carried by the machine's runtime, and reaches the bus on its own account. RunsAs string @@ -394,11 +398,11 @@ func boundBucketsOf(r Records) []string { // runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1, // ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on, -// or a check its health asks. A module with none of these is a process of its own on its own credential, and +// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and // carrying it would hand every session on the machine the runner's account with that module's state, seat // traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it // stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not // the test: a module with tools and its own credential is still carried (ADR 0175). func runnerServes(d Declared) bool { - return d.Module == RuntimeModule || d.NoAccount || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 + return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 } diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 09fd6215..c68fddda 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -201,6 +201,12 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio // name (novox/hq ADR 0305). Issued: issuedTools(m), } + // Whether the node's runtime loads a bundle of it: a helper that runs on the runner's account (issue 1329). + for _, b := range m.Bundles { + if len(b.Loads) > 0 { + d.Loads = true + } + } // Whether it can be given an account at all: delivered as its own secret named broker, so one // that declares none has nowhere to read it (novox/hq issue 195). if _, reads := m.OwnSecrets["broker"]; !reads { From c07f604047be28350792ffed1a3ffb49237a3480 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 23:23:23 +0000 Subject: [PATCH 3/3] Make the rollout tests wait for the plan look to return before their cleanups restore what it reads, which raced (issue 1338) --- cmd/mesh-controller/away_integration_test.go | 4 +++- cmd/mesh-controller/plan_looks.go | 20 ++++++++++++++----- cmd/mesh-controller/rollout_events_test.go | 12 ++++++++--- .../silent_mid_tier_integration_test.go | 4 +++- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/cmd/mesh-controller/away_integration_test.go b/cmd/mesh-controller/away_integration_test.go index 3cbd81ba..759b7ed9 100644 --- a/cmd/mesh-controller/away_integration_test.go +++ b/cmd/mesh-controller/away_integration_test.go @@ -174,7 +174,9 @@ func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) { }} first, stopFirst := context.WithCancel(ctx) defer stopFirst() - startRolloutEvents(first, open, js) + waitFirst := startRolloutEvents(first, open, js) + // Ends with the test before the cleanups restore what the look reads, which run after it (issue 1338). + t.Cleanup(func() { stopFirst(); waitFirst() }) if err := inv.SavePlan(ctx, &p); err != nil { t.Fatal(err) } diff --git a/cmd/mesh-controller/plan_looks.go b/cmd/mesh-controller/plan_looks.go index f0a28d08..ded34bcb 100644 --- a/cmd/mesh-controller/plan_looks.go +++ b/cmd/mesh-controller/plan_looks.go @@ -138,7 +138,9 @@ func askToLook() { // startPlanLooks starts the look at the open plans, and asks for the first: the controller took the lease, and a // plan left by a replaced controller resumes from there (ADR 0406 §6). It replaces the rollout's 30 second ticker. -func startPlanLooks(ctx context.Context, open *stores) { +// It answers a channel that is closed once the look has returned after the context ended, for a test that restores what +// the look reads (issue 1338). +func startPlanLooks(ctx context.Context, open *stores) <-chan struct{} { l := newLooker(func(ctx context.Context) bool { return lookAtPlans(ctx, open) }) l.first = func(ctx context.Context) { rearmWalkEnds(ctx, open) } lookerMu.Lock() @@ -152,8 +154,13 @@ func startPlanLooks(ctx context.Context, open *stores) { } lookerMu.Unlock() }() - go l.run(ctx) + done := make(chan struct{}) + go func() { + defer close(done) + l.run(ctx) + }() l.ask() + return done } // applySilentAfter is how long a machine may say nothing on a send before the plan names it silent: the mesh's @@ -163,8 +170,10 @@ var applySilentAfter = inventory.ApplySilentAfter // startRolloutEvents starts what a rollout advances on, for as long as the context lives (novox/hq ADR 0406 §5, issue 974): // the wait for each machine's report as a key on the bus that expires (where the bus will not take it a timer of its own // for that wait looks instead), a provisioner client's words, and the look at the plans, asked for first so a plan left -// by a replaced controller resumes. The serving controller's start and the tests of a rollout call this one. -func startRolloutEvents(ctx context.Context, open *stores, js *broker.JetStream) { +// by a replaced controller resumes. The serving controller's start and the tests of a rollout call this one. It answers +// a function that waits until the look at the plans has returned, which only a test that ends the context needs (issue +// 1338): the serving controller ignores it. +func startRolloutEvents(ctx context.Context, open *stores, js *broker.JetStream) (wait func()) { if err := startRolloutSilent(ctx, js); err != nil { fmt.Printf("plans: the wait for the machines' reports could not be put on the bus (%v); each is looked at by a timer of its own\n", err) } @@ -174,7 +183,8 @@ func startRolloutEvents(ctx context.Context, open *stores, js *broker.JetStream) if err := startPlanMovedLooks(ctx, js); err != nil { fmt.Printf("plans: the plans moved by other processes could not be followed (%v)\n", err) } - startPlanLooks(ctx, open) + done := startPlanLooks(ctx, open) + return func() { <-done } } // servingPlanSaved is what the serving controller does for each plan it keeps: said as plan-moved, looked at (a build, a diff --git a/cmd/mesh-controller/rollout_events_test.go b/cmd/mesh-controller/rollout_events_test.go index a90d359c..1109247c 100644 --- a/cmd/mesh-controller/rollout_events_test.go +++ b/cmd/mesh-controller/rollout_events_test.go @@ -179,7 +179,7 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { // The first controller. Its plan has tier 0 built and tier 1 not asked: nothing but the save asks anything. first, stopFirst := context.WithCancel(ctx) defer stopFirst() - startRolloutEvents(first, open, js) + waitFirst := startRolloutEvents(first, open, js) built := time.Now().UTC() p := inventory.Plan{ID: "plan-974", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2", Created: built, State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}}, @@ -199,12 +199,15 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) { // The controller is replaced: its looks and its watcher end. anchor reports meanwhile, to nobody. stopFirst() + waitFirst() time.Sleep(300 * time.Millisecond) reports("anchor") second, stopSecond := context.WithCancel(ctx) defer stopSecond() - startRolloutEvents(second, open, js) + waitSecond := startRolloutEvents(second, open, js) + // Ends with the test before the cleanups restore what the look reads, which run after it (issue 1338). + t.Cleanup(func() { stopSecond(); waitSecond() }) // Its first look finds anchor reported, so its key goes; laptop's stays, and is the new controller's to hear expire. eventually(t, "the new controller's first look deletes the key of the machine that reported", func() bool { h := heldKeys() @@ -321,7 +324,7 @@ func TestAWalkLetGoByTheDeliverVerbIsAskedWithNoOtherEvent(t *testing.T) { t.Cleanup(func() { inventory.PlanSaved = wasSaved }) started, stop := context.WithCancel(ctx) defer stop() - startRolloutEvents(started, open, js) + wait := startRolloutEvents(started, open, js) time.Sleep(500 * time.Millisecond) // the first look: the walk waits, and is asked nothing // The verb as its child process runs it: the delivery command, whose saves are not the serving controller's. @@ -333,6 +336,9 @@ func TestAWalkLetGoByTheDeliverVerbIsAskedWithNoOtherEvent(t *testing.T) { return verbAnswer{}, deliveryCommand(ctx, []string{"go", waiting.ID, "--why", "its turn"}) } t.Cleanup(func() { runTheVerb = wasRun }) + // Registered after every variable cleanup above, so it runs before them (last in, first out): the look at the plans + // has returned when checkEvents, inventory.PlanSaved and runTheVerb are restored (issue 1338). + t.Cleanup(func() { stop(); wait() }) handlers, _, err := seatToolHandlers() if err != nil { t.Fatal(err) diff --git a/cmd/mesh-controller/silent_mid_tier_integration_test.go b/cmd/mesh-controller/silent_mid_tier_integration_test.go index ac0a465e..a6ec577d 100644 --- a/cmd/mesh-controller/silent_mid_tier_integration_test.go +++ b/cmd/mesh-controller/silent_mid_tier_integration_test.go @@ -171,7 +171,9 @@ func TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToT }} first, stopFirst := context.WithCancel(ctx) defer stopFirst() - startRolloutEvents(first, open, js) + waitFirst := startRolloutEvents(first, open, js) + // Ends with the test before the cleanups restore what the look reads, which run after it (issue 1338). + t.Cleanup(func() { stopFirst(); waitFirst() }) if err := inv.SavePlan(ctx, &p); err != nil { t.Fatal(err) }