diff --git a/cmd/mesh-controller/plans_follow.go b/cmd/mesh-controller/plans_follow.go new file mode 100644 index 00000000..c13dedc7 --- /dev/null +++ b/cmd/mesh-controller/plans_follow.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// Following a plan (novox/hq issue 229): `plans wait ` holds its answer until the plan moves, reaches its +// next tier or ends, or until its bound passes, and says which. Before it, an agent following a rollout had +// `plans` answer once, in prose, and wrote a loop of its own round the mesh's tools to be told the plan moved. +// +// **No wait is longer than a call is answered within** (link.LongestAnswer, inside the console's thirty +// seconds): a longer one would answer "still running" and leave its caller to ask `calls` — the loop again. +// So a caller following a plan to its end asks again; each call answers the moment the plan moves. + +// maxPlanWait is the longest bound a wait takes, and the bound when none is given: what a call is answered +// within, less the time a verb's command takes to start and read the store. +var maxPlanWait = link.LongestAnswer - planWaitStart + +// planWaitStart is what the verb's window allows beyond the bound: starting the command and opening the stores. +const planWaitStart = 5 * time.Second + +// planWaitEvery is how often a wait reads the plan again. +const planWaitEvery = time.Second + +// The things a wait waits until. +var planWaitUntil = []string{"moved", "tier", "done"} + +// planWaitAsked checks what a wait is asked for: until (moved when empty) and a bound (a duration, or a number +// of seconds; maxPlanWait when empty). +func planWaitAsked(until, bound string) (string, time.Duration, error) { + if until == "" { + until = "moved" + } + known := false + for _, u := range planWaitUntil { + known = known || u == until + } + if !known { + return "", 0, fmt.Errorf("a wait is until %s, not %q", strings.Join(planWaitUntil, ", "), until) + } + d := maxPlanWait + if bound = strings.TrimSpace(bound); bound != "" { + var err error + if secs, isNumber := strconv.Atoi(bound); isNumber == nil { + d = time.Duration(secs) * time.Second + } else if d, err = time.ParseDuration(bound); err != nil { + return "", 0, fmt.Errorf("the bound %q is not a duration (such as 20s) or a number of seconds", bound) + } + if d <= 0 || d > maxPlanWait { + return "", 0, fmt.Errorf("the bound is at most %s, the longest a call is answered within; %s was asked "+ + "— to follow a plan longer, ask again: each call answers the moment the plan moves", maxPlanWait, d) + } + } + return until, d, nil +} + +// planEnded is a plan that will not move again: done, failed or superseded. A batch being assembled is not one. +func planEnded(p inventory.Plan) bool { return !p.Open() && !p.Batch() } + +// waitOnPlan reads the plan until what it waits for happens or the bound passes, and answers the plan as last +// read and what ended the wait: moved (any save of it), tier (its tier changed), done (it ended — which ends +// every wait, since nothing comes after it), or bound. Moved and tier are judged against the plan as first read. +func waitOnPlan(ctx context.Context, read func(context.Context) (inventory.Plan, error), until string, + bound, every time.Duration) (inventory.Plan, string, error) { + first, err := read(ctx) + if err != nil { + return inventory.Plan{}, "", err + } + if planEnded(first) { + return first, "done", nil + } + deadline := time.Now().Add(bound) + tick := time.NewTicker(every) + defer tick.Stop() + last := first + for { + select { + case <-ctx.Done(): + return last, "", ctx.Err() + case <-tick.C: + } + if !time.Now().Before(deadline) { + return last, "bound", nil + } + p, err := read(ctx) + if err != nil { + return last, "", err + } + last = p + switch { + case planEnded(p): + return p, "done", nil + case until == "moved" && p.Revision != first.Revision: + return p, "moved", nil + case until == "tier" && p.Tier != first.Tier: + return p, "tier", nil + } + } +} + +// planWaited is what `plans wait` answers: the plan, what ended the wait, and the line a person reads (`text`, +// which the verb answers as its output and not again in the data). +type planWaited struct { + // Came is what ended the wait: moved, tier, done, or bound when nothing did. + Came string `json:"came"` + Until string `json:"until"` + Waited string `json:"waited"` + Plan planStatus `json:"plan"` + // Revision is the plan's as read: a caller asking again with the same plan sees whether it moved since. + Revision int64 `json:"revision"` + Text string `json:"text"` +} + +func planWaitedDoc(p inventory.Plan, st planStatus, until, came string, waited time.Duration, line string) planWaited { + said := map[string]string{ + "moved": "moved", "tier": "reached another tier", "done": "ended", + "bound": "did not " + map[string]string{"moved": "move", "tier": "reach another tier", "done": "end"}[until], + }[came] + text := fmt.Sprintf("%s %s in %s (waited until %s)\n%s — %s\n", p.ID, said, waited.Round(time.Second), until, + p.ID, line) + return planWaited{Came: came, Until: until, Waited: waited.Round(time.Millisecond).String(), Plan: st, + Revision: p.Revision, Text: text} +} + +// planWaitCommand is `plans wait `: the plan followed until it moves, or the bound passes. +func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsked, boundAsked string) error { + until, bound, err := planWaitAsked(untilAsked, boundAsked) + if err != nil { + return err + } + began := time.Now() + p, came, err := waitOnPlan(ctx, func(ctx context.Context) (inventory.Plan, error) { + return inv.PlanByID(ctx, id) + }, until, bound, planWaitEvery) + if err != nil { + return err + } + // Said as `plans ` says its first line, and as `status` says the plan. + now, one := time.Now(), []inventory.Plan{p} + bounds, pause := readTierBounds(ctx, inv, now), buildSeatPause(ctx, inv, one) + line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string { + on, _ := inv.Running(ctx, module) + return on + }, waitsOf(ctx, inv, one)) + doc := planWaitedDoc(p, planStatuses(one, now, pause, bounds)[0], until, came, now.Sub(began), line) + return json.NewEncoder(os.Stdout).Encode(doc) +} diff --git a/cmd/mesh-controller/plans_follow_test.go b/cmd/mesh-controller/plans_follow_test.go new file mode 100644 index 00000000..844b99e7 --- /dev/null +++ b/cmd/mesh-controller/plans_follow_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// A plan as the store would answer it at each read: the reads are counted, and each answers the next. +type planReads struct { + plans []inventory.Plan + n int +} + +func (r *planReads) read(context.Context) (inventory.Plan, error) { + p := r.plans[min(r.n, len(r.plans)-1)] + r.n++ + return p, nil +} + +func followedPlan(rev int64, state string, tier int) inventory.Plan { + return inventory.Plan{ID: "plan-1", Repository: "novox/x", Commit: "abc", State: state, Tier: tier, + Tiers: [][]string{{"a"}, {"b"}}, Revision: rev} +} + +// **`plans` waits for a plan, and says what ended the wait** (novox/hq issue 229): an agent following a +// rollout is told when the plan moved, reached its next tier or ended, or that the bound passed — in one +// call, without a loop of its own. +func TestAWaitForAPlanEndsWhenWhatItWaitsForHappens(t *testing.T) { + cases := []struct { + name string + until string + reads []inventory.Plan + came string + times int + }{ + {"moved: any save", "moved", []inventory.Plan{ + followedPlan(1, inventory.PlanBuilding, 0), followedPlan(1, inventory.PlanBuilding, 0), + followedPlan(2, inventory.PlanBuilding, 0)}, "moved", 3}, + {"tier: a save inside the tier is not enough", "tier", []inventory.Plan{ + followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanBuilding, 0), + followedPlan(3, inventory.PlanRolling, 1)}, "tier", 3}, + {"tier: a plan that ended has no next tier to wait for", "tier", []inventory.Plan{ + followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanFailed, 0)}, "done", 2}, + {"done: a new tier is not the end", "done", []inventory.Plan{ + followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanRolling, 1), + followedPlan(3, inventory.PlanDone, 1)}, "done", 3}, + {"done: a plan already ended answers at once", "done", []inventory.Plan{ + followedPlan(4, inventory.PlanSuperseded, 1)}, "done", 1}, + {"the bound passes", "moved", []inventory.Plan{followedPlan(1, inventory.PlanBuilding, 0)}, "bound", -1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := &planReads{plans: c.reads} + p, came, err := waitOnPlan(context.Background(), r.read, c.until, 50*time.Millisecond, time.Millisecond) + if err != nil { + t.Fatal(err) + } + if came != c.came { + t.Fatalf("the wait ended as %q, want %q", came, c.came) + } + if c.times > 0 && r.n != c.times { + t.Errorf("read the plan %d times, want %d: it answered before or after what it waited for", r.n, c.times) + } + if want := c.reads[min(r.n, len(c.reads))-1]; p.Revision != want.Revision { + t.Errorf("answered revision %d, not the plan as it was last read (%d)", p.Revision, want.Revision) + } + }) + } +} + +// What a wait is asked for is checked before anything is read: what it waits until, and a bound no longer than +// a call is answered within. +func TestAWaitIsRefusedWhatItCannotDo(t *testing.T) { + for _, bad := range []struct{ until, bound string }{{"landed", ""}, {"", "1h"}, {"", "-3s"}, {"", "soon"}} { + if _, _, err := planWaitAsked(bad.until, bad.bound); err == nil { + t.Errorf("until %q, bound %q was taken", bad.until, bad.bound) + } + } + until, bound, err := planWaitAsked("", "") + if err != nil || until != "moved" || bound != maxPlanWait { + t.Fatalf("the defaults are %q %s %v, want moved and %s", until, bound, err, maxPlanWait) + } + if _, bound, _ := planWaitAsked("tier", "12"); bound != 12*time.Second { + t.Errorf("a bare number is seconds; read %s", bound) + } +} + +// **The answer is data, with its prose as a field beside it** (novox/hq issue 229): the verb's `answer` carries +// the plan and what ended the wait, and its output is the line a person reads — never JSON inside a string. +func TestAWaitForAPlanAnswersAsData(t *testing.T) { + p := followedPlan(7, inventory.PlanRolling, 1) + st := planStatuses([]inventory.Plan{p}, time.Now(), pauseView{}, tierBounds{})[0] + doc := planWaitedDoc(p, st, "tier", "tier", 3*time.Second, "rolling, tier 2 of 2") + stdout, _ := json.Marshal(doc) + a := answerOf([]string{"plans", "wait", "plan-1"}, stdout, "", true) + data, ok := a.Answer.(map[string]any) + if !ok { + t.Fatalf("the answer is not data: %v", a.Answer) + } + if data["came"] != "tier" || data["revision"] != float64(7) { + t.Errorf("the data does not say what ended the wait: %v", data) + } + if plan, _ := data["plan"].(map[string]any); plan["state"] != "rolling" || plan["tier"] != float64(1) { + t.Errorf("the data does not carry the plan: %v", data["plan"]) + } + if _, text := data["text"]; text { + t.Errorf("the prose is in the data too: %v", data) + } + if strings.Contains(a.Output, "{") || !strings.Contains(a.Output, "plan-1") || !strings.Contains(a.Output, "tier") { + t.Errorf("the output is not the line a person reads: %q", a.Output) + } +} + +// The verb composes the wait, and asks for a window as long as its bound. +func TestThePlansVerbComposesAWait(t *testing.T) { + argv, err := argvFor("plans", map[string]any{"wait": "plan-1", "until": "done", "bound": "15s"}) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(argv, " "); got != "plans wait plan-1 --until done --bound 15s" { + t.Fatalf("composed %q", got) + } + if _, err := argvFor("plans", map[string]any{"until": "done"}); err == nil { + t.Error("until without wait was taken") + } + if _, err := argvFor("plans", map[string]any{"wait": "plan-1", "bound": "1h"}); err == nil { + t.Error("a bound past what a call can wait was taken") + } +} + +// Live, with the store: `plans wait` run as the verb runs it answers when the plan reaches its next tier, as +// data, and says the bound passed when nothing moved (novox/hq issue 229). +func TestAPlanFollowedFromTheStoreAnswersWhenItMoves(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + now := time.Now().UTC() + walk := inventory.Plan{ID: "plan-229", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c229c229", + Created: now, State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}}, + Modules: map[string]*inventory.PlanModule{"a": {}, "b": {}}} + if err := open.inventory.SavePlan(ctx, &walk); err != nil { + t.Fatal(err) + } + moved := make(chan error, 1) + go func() { + time.Sleep(1500 * time.Millisecond) + p, err := open.inventory.PlanByID(ctx, walk.ID) + if err == nil { + p.Tier, p.State = 1, inventory.PlanRolling + err = open.inventory.SavePlan(ctx, &p) + } + moved <- err + }() + began := time.Now() + var got planWaited + out := printed(t, func() error { + return plansCommand(ctx, []string{"wait", walk.ID, "--until", "tier", "--bound", "15s"}) + }) + if err := <-moved; err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("plans wait did not answer data: %v: %q", err, out) + } + if got.Came != "tier" || got.Plan.Tier != 1 || got.Plan.State != inventory.PlanRolling { + t.Fatalf("the wait answered %+v", got) + } + if waited := time.Since(began); waited > 10*time.Second { + t.Errorf("it answered after %s, not when the plan moved", waited) + } + out = printed(t, func() error { + return plansCommand(ctx, []string{"wait", walk.ID, "--bound", "2"}) + }) + if err := json.Unmarshal([]byte(out), &got); err != nil || got.Came != "bound" || got.Until != "moved" { + t.Fatalf("a plan that did not move answered %+v (%v)", got, err) + } +} diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 1710c59a..452ae757 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -1463,6 +1463,10 @@ func plansCommand(ctx context.Context, args []string) error { modules := set.String("modules", "", "or the modules it would change, comma-separated") moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+ "comma-separated, as the forge's announcer says them (novox/hq issue 278)") + // Following a plan (novox/hq issue 229): `plans wait `. + until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)") + bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+ + "longest a call is answered within)") // Ending a plan by hand is a repair, and says why (novox/hq to-be 45 §7). why := addHandActFlags(set) positionals, err := parseAround(set, args) @@ -1482,6 +1486,9 @@ func plansCommand(ctx context.Context, args []string) error { defer open.Close() inv := open.inventory now := time.Now() + if len(positionals) == 2 && positionals[0] == "wait" { + return planWaitCommand(ctx, inv, positionals[1], *until, *bound) + } if len(positionals) == 1 { p, err := inv.PlanByID(ctx, positionals[0]) if err != nil { diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 3e4d1d18..44598bbf 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -365,6 +365,21 @@ func (a *verbArguments) commandLine() ([]string, error) { return argv, nil } } + if id := str("wait"); id != "" { + // Following a plan (novox/hq issue 229): checked here too, so a call asking what no wait can do is + // refused before a command is started. + if _, _, err := planWaitAsked(a.given["until"], a.given["bound"]); err != nil { + return nil, err + } + argv := []string{"plans", "wait", id} + if u := str("until"); u != "" { + argv = append(argv, "--until", u) + } + if b := str("bound"); b != "" { + argv = append(argv, "--bound", b) + } + return argv, nil + } if id := str("id"); id != "" { return []string{"plans", id}, nil } @@ -985,12 +1000,14 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co // The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239. "delivery": true, // The build queue, each ask with its position among the waiting (novox/hq issue 455). - "queue": true} + "queue": true, + // A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data. + "plans": true} // tableBesideVerbs are the JSON verbs whose document carries the table a person reads as its `text`: the // verb answers that table as its output, as it did before it answered data, and the rest as `answer` — // both from the one read, so they never disagree (novox/hq issue 455). -var tableBesideVerbs = map[string]bool{"queue": true} +var tableBesideVerbs = map[string]bool{"queue": true, "plans": true} // overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the // command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs @@ -1264,6 +1281,16 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) { // Whatever it did, `status` is composed again once it has. defer statusFrom.nudge() } + if verb == "plans" && len(argv) > 1 && argv[1] == "wait" { + // A wait holds its answer up to its bound (novox/hq issue 229), and is answered in full inside it. + asked := "" + if i := slices.Index(argv, "--bound"); i > 0 && i+1 < len(argv) { + asked = argv[i+1] + } + if _, bound, err := planWaitAsked("", asked); err == nil { + link.AnswersWithin(ctx, bound+planWaitStart) + } + } if answer, read := readHere(ctx, argv); read { return answer, nil } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 93a62ce8..af7cc055 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -116,9 +116,15 @@ var ControllerVerbs = []Verb{ "limit": "how many builds to list (default 20); not with log", }, nil)}, {Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " + - "the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.", + "the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id; " + + "or, given wait, one plan followed until it moves.", Input: schema(map[string]string{ - "id": "a plan's id (as `plans` lists them): that plan, tier by tier", + "id": "a plan's id (as `plans` lists them): that plan, tier by tier", + "wait": "a plan's id: wait until it moves, then answer it as data — the plan, and what ended the wait " + + "(moved, tier, done, or bound); ask again to follow it further (novox/hq issue 229)", + "until": "with wait: moved (any change, the default), tier (it reaches another tier) or done (it ends)", + "bound": "with wait: how long to wait at most, as 15s or seconds — 20s, the default, is the longest, " + + "inside what a call is answered within", "stop": "a plan's id: stop it — what was asked still builds, nothing further is asked", "close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)", "retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)", diff --git a/internal/link/calls.go b/internal/link/calls.go index fa1d4740..262defee 100644 --- a/internal/link/calls.go +++ b/internal/link/calls.go @@ -601,6 +601,24 @@ func Acknowledge(ctx context.Context) { } } +// LongestAnswer is the longest a handler that waits on purpose may hold its caller's one answer +// (AnswersWithin, novox/hq issue 229): inside the console's thirty seconds with room for the trip back, +// and inside the bus's window for an answer (broker.ResponseTTL). A variable so a test need not wait. +var LongestAnswer = 25 * time.Second + +type answerWithinKey struct{} + +// AnswersWithin asks that the call's caller be answered in full if the handler ends within d of the +// call's start, rather than within AnswerWithin: for a handler that waits on purpose, with a bound — +// `plans` waiting for a plan to move (novox/hq issue 229). Past d, or past LongestAnswer whichever is +// shorter, its caller is told it is running, as for any call. Never shortens the window; without effect +// outside a served call, and once the caller was answered. +func AnswersWithin(ctx context.Context, d time.Duration) { + if f, ok := ctx.Value(answerWithinKey{}).(func(time.Duration)); ok { + f(d) + } +} + // running is the interim answer: what a caller reads when the call has not finished. func running(c *Call, within time.Duration, acknowledged bool, follow string) []byte { why := fmt.Sprintf("it has not finished in %s", within) @@ -645,6 +663,13 @@ func (l *CallLog) serveCallWithin(seat, verb string, args json.RawMessage, reply acknowledged := make(chan struct{}) var once sync.Once ctx = context.WithValue(ctx, answerNowKey{}, func() { once.Do(func() { close(acknowledged) }) }) + longer := make(chan time.Duration, 1) + ctx = context.WithValue(ctx, answerWithinKey{}, func(d time.Duration) { + select { + case longer <- d: + default: + } + }) type outcome struct { body []byte @@ -703,17 +728,29 @@ func (l *CallLog) serveCallWithin(seat, verb string, args json.RawMessage, reply } } } - timer := time.NewTimer(AnswerWithin) + within, start := AnswerWithin, time.Now() + timer := time.NewTimer(within) defer timer.Stop() - select { - case o := <-done: - l.finish(c, o.body, o.failed, false) - say(o.body) - return - case <-acknowledged: - say(running(c, AnswerWithin, true, l.Follow)) - case <-timer.C: - say(running(c, AnswerWithin, false, l.Follow)) +waiting: + for { + select { + case o := <-done: + l.finish(c, o.body, o.failed, false) + say(o.body) + return + case <-acknowledged: + say(running(c, within, true, l.Follow)) + break waiting + case d := <-longer: + // A handler waiting on purpose (AnswersWithin): its window is stretched, never past LongestAnswer. + if d = min(d, LongestAnswer); d > within { + within = d + timer.Reset(time.Until(start.Add(d))) + } + case <-timer.C: + say(running(c, within, false, l.Follow)) + break waiting + } } o := <-done l.finish(c, o.body, o.failed, true) diff --git a/internal/link/calls_wait_test.go b/internal/link/calls_wait_test.go new file mode 100644 index 00000000..6c3ef638 --- /dev/null +++ b/internal/link/calls_wait_test.go @@ -0,0 +1,55 @@ +package link + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +// **A handler that waits on purpose is answered in full when it ends inside the window it asked for** +// (novox/hq issue 229): `plans` asked to wait for a plan holds its caller's one answer until the plan +// moves or its bound passes, rather than being answered "still running" at AnswerWithin. +func TestAHandlerThatWaitsOnPurposeIsAnsweredInFullInsideItsWindow(t *testing.T) { + shortWindow(t, 20*time.Millisecond) + l, a := NewCallLog(), newAnswers(t) + l.serveCall("mesh-controller", "plans", json.RawMessage(`{"wait":"plan-1"}`), "_INBOX.x.3", + func(ctx context.Context, _ json.RawMessage) (any, error) { + AnswersWithin(ctx, 200*time.Millisecond) + time.Sleep(80 * time.Millisecond) // past AnswerWithin, inside what it asked for + return "plan-1 moved", nil + }, a.respond, nil) + if got := a.only()["result"]; got != "plan-1 moved" { + t.Fatalf("a waiting handler inside its window was answered %v, not in full", got) + } +} + +// What a handler may ask for is bounded: past LongestAnswer its caller is told it is running, as any +// call is, so no answer is ever later than the console waits. +func TestAWaitIsNeverLongerThanTheLongestAnswer(t *testing.T) { + shortWindow(t, 10*time.Millisecond) + was := LongestAnswer + LongestAnswer = 30 * time.Millisecond + t.Cleanup(func() { LongestAnswer = was }) + l, a := NewCallLog(), newAnswers(t) + release := make(chan struct{}) + finished := make(chan struct{}) + go func() { + l.serveCall("mesh-controller", "plans", nil, "_INBOX.x.4", func(ctx context.Context, _ json.RawMessage) (any, error) { + AnswersWithin(ctx, time.Hour) + <-release + return "late", nil + }, a.respond, nil) + close(finished) + }() + select { + case <-a.sent: + case <-time.After(2 * time.Second): + t.Fatal("a handler asking for an hour held its caller past LongestAnswer") + } + if got, _ := a.only()["result"].(map[string]any); got["running"] != true { + t.Fatalf("past LongestAnswer the caller was answered %v, not that it is running", a.only()) + } + close(release) + <-finished +} diff --git a/testdata/store-tests b/testdata/store-tests index 591ba44c..78c6a831 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -7,6 +7,7 @@ ./cmd/mesh-controller TestAStopNamesWhatStaysOnTheMachinesAndWhatStillCarriesIt ./cmd/mesh-controller TestAStopWithoutAMergeLeavesItOutOfABatch ./cmd/mesh-controller TestSavingPlansAndMovingMergesIsOneAct +./cmd/mesh-controller TestAPlanFollowedFromTheStoreAnswersWhenItMoves ./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered ./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend ./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore