diff --git a/cmd/mesh-host/apply_context_test.go b/cmd/mesh-host/apply_context_test.go index 2bb99be..a78a993 100644 --- a/cmd/mesh-host/apply_context_test.go +++ b/cmd/mesh-host/apply_context_test.go @@ -27,6 +27,7 @@ func TestAnApplyOutlivesTheLinksContext(t *testing.T) { // An apply is bounded all the same: a step that hangs (a docker call that never returns) cannot hold the node agent for // ever (the review of issue 1945). func TestAnApplyIsBounded(t *testing.T) { + t.Run("the marks of the haves never hold the pass", marksOfTheHavesNeverHoldThePass) ctx, done := applyContext(context.Background()) defer done() at, ok := ctx.Deadline() diff --git a/cmd/mesh-host/marker_test.go b/cmd/mesh-host/marker_test.go index 68c86e5..429014f 100644 --- a/cmd/mesh-host/marker_test.go +++ b/cmd/mesh-host/marker_test.go @@ -8,15 +8,16 @@ import ( // Issue 1965 (shanks 2026-11-05 22:22): the marks of the haves never hold the composition pass. A mark that stalls on the // bus is cut at markWithin, a mark set while one runs replaces the pending one of its name, and set itself never waits. -func TestMarksOfTheHavesNeverHoldThePass(t *testing.T) { - setMarkWithin(t, 30*time.Millisecond) +func marksOfTheHavesNeverHoldThePass(t *testing.T) { + // The bound is the test's own: the stalled mark is released by a channel, never by a real wait. + setMarkWithin(t, time.Hour) m := newMarker(context.Background()) + release := make(chan struct{}) var ran []string stalled := make(chan struct{}) - began := time.Now() - m.set("refused", func(ctx context.Context) { // a have write that stalls until its bound + m.set("refused", func(ctx context.Context) { // a have write that stalls until the test lets it go close(stalled) - <-ctx.Done() + <-release ran = append(ran, "refused-stalled") }) <-stalled @@ -24,9 +25,8 @@ func TestMarksOfTheHavesNeverHoldThePass(t *testing.T) { m.set("kept", func(context.Context) { ran = append(ran, "kept-old") }) m.set("kept", func(context.Context) { ran = append(ran, "kept-new") }) m.set("refused", func(context.Context) { ran = append(ran, "refused-new") }) - if took := time.Since(began); took > 20*time.Millisecond { - t.Errorf("setting marks took %v; it must not wait on the stalled one", took) - } + // All four sets above returned while the first mark still stalled: set never waits on it. + close(release) m.wait() want := []string{"refused-stalled", "kept-new", "refused-new"} if len(ran) != len(want) { diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index a1b4568..6cf6335 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -703,8 +703,12 @@ type agentNow struct { // read no haves yet, what runs is this and never the retired kept declaration (a bus link that drops restarts the // agent, and every fill in that window was refused; issue 924). last *runsView + // view reads an agent's view; viewOf unless a test stands in for an agent that holds the node. + view func(*agent.Agent) *runsView } +func viewOf(a *agent.Agent) *runsView { return &runsView{a.Everything(), a.HoldsNode()} } + // runsView is what an agent said runs, to be read after the agent stopped. type runsView struct { everything []compositions.Composed @@ -714,7 +718,11 @@ type runsView struct { func (c *agentNow) set(a *agent.Agent) { c.mu.Lock() if a == nil && c.ag != nil { - if v := (&runsView{c.ag.Everything(), c.ag.HoldsNode()}); len(v.everything) > 0 || v.holdsNode { + read := c.view + if read == nil { + read = viewOf + } + if v := read(c.ag); len(v.everything) > 0 || v.holdsNode { c.last = v } } @@ -722,6 +730,22 @@ func (c *agentNow) set(a *agent.Agent) { c.mu.Unlock() } +func (c *agentNow) viewFn() func(*agent.Agent) *runsView { + c.mu.Lock() + defer c.mu.Unlock() + if c.view == nil { + return viewOf + } + return c.view +} + +// holdsNode drops the kept view once the agent in hand holds the node: its own view is complete from then on. +func (c *agentNow) holdsNode() { + c.mu.Lock() + c.last = nil + c.mu.Unlock() +} + // lastView is the view the last agent held when it stopped, nil when none held any. func (c *agentNow) lastView() *runsView { c.mu.Lock() @@ -914,7 +938,10 @@ func runsFromAgent(current *agentNow) func(string, ed25519.PublicKey) (*declarat kept := func() (*declaration.Declaration, error) { return keptDeclaration(statePath, signer) } var view *runsView if ag := current.get(); ag != nil { - view = &runsView{ag.Everything(), ag.HoldsNode()} + view = current.viewFn()(ag) + if view.holdsNode { + current.holdsNode() + } } // An agent that has read no haves yet, or none: the last view held, never the retired kept declaration. if view == nil || (len(view.everything) == 0 && !view.holdsNode) { diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 8d0a787..50da892 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -63,6 +63,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) { // What the take act, the judgement of directories and the filling read is what runs, not the frozen kept declaration. func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { + t.Run("a fill while the agent restarts", aFillWhileTheAgentRestartsIsWrittenAfterwards) setRunsHere(func(string, ed25519.PublicKey) (*declaration.Declaration, error) { return &declaration.Declaration{Version: 1, Resources: nil, Provisioner: "from-agent"}, nil }) @@ -243,7 +244,7 @@ func TestASharedTargetWithAnotherIDIsNotDropped(t *testing.T) { // Issue 924: the bus link drops, the agent restarts, and the filling is asked while it holds nothing yet. What runs is // then the last view the node held, never the retired kept declaration; and a fill refused for want of any is owed and // told once the agent holds the node again, so every file is written afterwards. -func TestAFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { +func aFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { keptRetired.Store(true) defer keptRetired.Store(false) node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Provisioner: "mesh-node-provisioner"}} @@ -251,7 +252,15 @@ func TestAFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { view := &runsView{everything: append(files, node), holdsNode: true} var placed []string - current := &agentNow{} + // The agent holds the node, then the link drops and it stops: set(nil) keeps its view. + holding := true + current := &agentNow{view: func(*agent.Agent) *runsView { + if holding { + return view + } + return &runsView{} + }} + current.set(agent.New(agent.Env{})) e := &filling.Engine{ Declared: func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) }, UIDOf: func(string) (uint32, error) { return 4242, nil }, @@ -269,16 +278,28 @@ func TestAFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { t.Fatal(err) } - // The node never held a view (a cold start): refused, and owed. + // The next agent has read no haves and the previous one's view is gone (a cold start): refused, and owed. + cold := &agentNow{} + cold.set(agent.New(agent.Env{})) + cold.set(nil) + e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(cold)("/nowhere", nil) } if a := fill(); a.Failed == "" && a.Refused == "" { t.Fatalf("a fill with nothing read was accepted: %+v", a) } - // The agent stopped holding the node: the last view it held answers, and the fill is written. - current.last = view + // The link drops: set(nil) keeps the view the agent held, and the fill during the restart is written. + e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) } + current.set(nil) if told, err := e.Knock(socket); err != nil || told == "" { t.Fatalf("the refused fill was not owed and told: %q, %v", told, err) } if a := fill(); a.Failed != "" || a.Refused != "" || len(placed) != 1 { t.Fatalf("the fill during the restart was not written: %+v, placed %v", a, placed) } + // The new agent holds the node: its own view answers and the kept one is dropped. + next := agent.New(agent.Env{}) + current.set(next) + holding = true + if _, err := runsFromAgent(current)("/nowhere", nil); err != nil || current.lastView() != nil { + t.Fatalf("the kept view stays after the new agent holds the node: %v, %v", err, current.lastView()) + } } diff --git a/internal/selfcompose/compose_while_passing_test.go b/internal/selfcompose/compose_while_passing_test.go index ed56149..ea37027 100644 --- a/internal/selfcompose/compose_while_passing_test.go +++ b/internal/selfcompose/compose_while_passing_test.go @@ -21,7 +21,7 @@ import ( // Issue 1965 (shanks 2026-11-05 22:27: the verb compose gave no answer in 30 s while a pass waited on the bus): an ask // that comes while a pass runs is answered at once, says one more pass is queued and what the running one waits on, and // the queued pass is made when the running one ends. -func TestComposeAnsweredWhileAPassRuns(t *testing.T) { +func composeAnsweredWhileAPassRuns(t *testing.T) { b := newFakeBus() ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index a1a45f8..94a25d7 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -69,6 +69,7 @@ func (f *fakeComposed) written() []string { // the same build landing again writes nothing. And a module's keys are kept while among the five it wrote last or named // by a want, and deleted otherwise. func TestTheNodePublishesItsCompositions(t *testing.T) { + t.Run("the verb compose is answered while a pass runs", composeAnsweredWhileAPassRuns) b := newFakeBus() fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) ofp := meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64)