Files
mesh-tools/node-tools/internal/console/wait_test.go
T
jschoubben 0b0f879e86 E-4: a misspelt event name is refused, not waited on for ever (issue 1966)
Before it subscribes, mesh_wait checks each name's emitter against the catalog
half the tool runner holds (its seats' definitions and its modules' entries),
refusing one neither knows and naming the half and its revisions; a known
emitter's undeclared event is waited on and its timed-out answer says so; while
the half is not yet read the answer says the names were unchecked. The first
views answer the halves by branch for it.

Builds: design 34 every-event §14, §19 (rows of the table of refusals)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:11:52 +01:00

371 lines
15 KiB
Go

package console
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"reflect"
"sort"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
// waitingRuntime is the tool runner on a machine, issued no waits (novox/hq ADR 0670 §2): the surface over its
// connection, and the mesh to publish on.
func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) {
t.Helper()
mesh := mt.New(t)
mesh.Issue(t, mt.MembershipOf("node-tools", "desk", false, nil))
conn := connect(t, "node-tools", "desk")
mt.Until(t, func() error {
if conn.Membership("node-tools") == nil {
return os.ErrNotExist
}
return nil
})
return NewSurface(conn, busSource{conn}, "desk.node-tools"), mesh
}
// halfSource is a Source that also holds a catalog half, as the tool runner's first views do.
type halfSource struct {
Source
halves map[string]meshcatalogclient.Half
}
func (h halfSource) Halves() (map[string]meshcatalogclient.Half, error) { return h.halves, nil }
// listening waits until the surface's connection holds n more subscriptions than before: a wait has subscribed.
func listening(t *testing.T, s *Surface, before, n int) {
t.Helper()
mt.Until(t, func() error {
if s.conn.Subscriptions() < before+n {
return os.ErrNotExist
}
return nil
})
}
// callWait is one tools/call of mesh_wait, answered as an agent host reads it.
func callWait(s *Surface, args map[string]any) (text string, isError bool) {
raw, _ := json.Marshal(map[string]any{"name": verbWait, "arguments": args})
r := s.Handle(Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call", Params: raw})
result, _ := r.Result.(map[string]any)
content, _ := result["content"].([]map[string]any)
var parts []string
for _, c := range content {
parts = append(parts, c["text"].(string))
}
isErr, _ := result["isError"].(bool)
return strings.Join(parts, "\n"), isErr
}
// novox/hq ADR 0367 §1, ADR 0670 §1 (design 34 every-event §13, §15, §19): mesh_wait answers the first event that
// matches — by its name and its filter — with no membership naming it: its whole name, the namespace it arrived on, its
// body, when it arrived and when it was published, within a second of its publication, and ignores the others.
func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
s, mesh := waitingRuntime(t)
type answer struct {
text string
isErr bool
at time.Time
}
type heardAnswer struct {
Heard struct {
Subject string `json:"subject"`
From string `json:"from"`
Body map[string]any `json:"body"`
At time.Time `json:"at"`
Published *string `json:"published"`
} `json:"heard"`
Read string `json:"read"`
}
nc, err := nats.Connect(mt.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
done := make(chan answer, 1)
before := s.conn.Subscriptions()
go func() {
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-delivery-view.delivered"},
"filter": map[string]any{"module": "netcheck"}, "timeout": 20})
done <- answer{text, isErr, time.Now()}
}()
// Once it listens: another module's delivery, another event, then the one it waits for, with its own time.
listening(t, s, before, 6)
mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.delivered", map[string]any{"module": "grafana"})
mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.planned", map[string]any{"module": "netcheck"})
msg := nats.NewMsg("mesh.mod.mesh-delivery-view.event.delivered")
msg.Header.Set("x-time", "2026-11-12T03:00:00.5Z")
msg.Data = []byte(`{"module":"netcheck"}`)
published := time.Now()
if err := nc.PublishMsg(msg); err != nil {
t.Fatal(err)
}
var got answer
select {
case got = <-done:
case <-time.After(10 * time.Second):
t.Fatal("mesh_wait did not answer the matching event")
}
if got.isErr {
t.Fatalf("mesh_wait failed: %s", got.text)
}
if got.at.Sub(published) > time.Second {
t.Errorf("answered %s after the event was published, want within a second", got.at.Sub(published))
}
var heard heardAnswer
if err := json.Unmarshal([]byte(got.text), &heard); err != nil {
t.Fatalf("not an answer: %s", got.text)
}
if h := heard.Heard; h.Subject != "mesh-delivery-view.delivered" || h.From != "module" || h.Body["module"] != "netcheck" ||
h.At.IsZero() || h.Published == nil || *h.Published != "2026-11-12T03:00:00.5Z" || !strings.Contains(heard.Read, "only wakes you") {
t.Errorf("answered %s, want netcheck's delivery from a module with its times", got.text)
}
// And a seat's `decided` of one asker, heard by `operator-channel.decided`: its whole name, from the seat, with no
// time of its own, and the answer read only with claude_code_answer.
before = s.conn.Subscriptions()
go func() {
text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided", "timeout": 20})
done <- answer{text, isErr, time.Now()}
}()
listening(t, s, before, 6)
// A seat's event, said by the router on core NATS here: no stream of the test's bus carries the seat.
if err := connect(t, "messenger", "").Say("mesh.seat.operator-channel.event.decided.claude-code", []byte(`{"ask":"a1"}`)); err != nil {
t.Fatal(err)
}
select {
case got = <-done:
heard = heardAnswer{}
_ = json.Unmarshal([]byte(got.text), &heard)
if h := heard.Heard; got.isErr || h.Subject != "operator-channel.decided.claude-code" || h.From != "seat" ||
h.Published != nil || !strings.Contains(heard.Read, "claude_code_answer") {
t.Errorf("the asker's decided: %s", got.text)
}
case <-time.After(10 * time.Second):
t.Fatal("mesh_wait did not answer the asker's decided")
}
}
// It says it timed out at its timeout, and leaves no subscription behind (ADR 0367 §1, §2).
func TestMeshWaitSaysItTimedOutAndKeepsNothing(t *testing.T) {
s, _ := waitingRuntime(t)
before := s.conn.Subscriptions()
start := time.Now()
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": 1})
if took := time.Since(start); took < time.Second || took > 3*time.Second {
t.Errorf("a one-second wait took %s", took)
}
// Before the catalog half is read, names are not checked, and the answer says so (design 34 every-event §14).
if isErr || !strings.Contains(text, `"timed-out": true`) || !strings.Contains(text, `"unchecked"`) {
t.Errorf("a wait that heard nothing: %s", text)
}
if after := s.conn.Subscriptions(); after != before {
t.Errorf("%d subscriptions before the wait, %d after it", before, after)
}
}
// Its timeout is four minutes when none is given and ten at most: a longer one, or none at all, is refused
// with the bound, never shortened in silence.
func TestMeshWaitsTimeoutIsBounded(t *testing.T) {
s, _ := waitingRuntime(t)
if WaitDefault != 4*time.Minute || WaitLongest != 10*time.Minute {
t.Errorf("the bounds are %s and %s, want 4m and 10m (ADR 0367 §1)", WaitDefault, WaitLongest)
}
for _, timeout := range []any{601, 0, -5, "soon"} {
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": timeout})
if !isErr || !strings.Contains(text, "600") {
t.Errorf("a timeout of %v: %s", timeout, text)
}
}
}
// novox/hq ADR 0670 §1, §2 (design 34 every-event §13, §16, §19): mesh_wait refuses loudly and never loops: a name with
// a wildcard, of one token, more than 16 names, none, or a from of another word is refused at once naming the rule; a
// wait whose every subscription the bus refuses answers after its timeout, in words naming no wait grant.
func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) {
s, _ := waitingRuntime(t)
var seventeen []any
for i := 0; i < 17; i++ {
seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i))
}
for _, c := range []struct {
args map[string]any
says string
}{
{map[string]any{"subjects": []any{"mesh-knowledge.*"}}, "[A-Za-z0-9_-]+"},
{map[string]any{"subjects": []any{"operator-channel.decided.>"}}, "[A-Za-z0-9_-]+"},
{map[string]any{"subjects": []any{"mesh-knowledge"}}, "fewer than two tokens"},
{map[string]any{"subjects": seventeen}, "at most 16"},
{map[string]any{"subjects": []any{}}, "at least one event"},
{map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "from": "bench"}, "seat, module or stream"},
} {
c.args["timeout"] = 1
start := time.Now()
text, isErr := callWait(s, c.args)
if !isErr || !strings.Contains(text, c.says) || strings.Contains(text, "manifest") {
t.Errorf("%v: %s, want refused saying %q", c.args, text, c.says)
}
if took := time.Since(start); took > 500*time.Millisecond {
t.Errorf("%v was refused after %s, want at once", c.args, took)
}
}
// E §14: against the catalog half, a misspelt emitter is refused naming the half and its revision, a known emitter's
// undeclared event is waited on and its timeout says so, and a declared one says nothing of it.
half := NewSurface(s.conn, halfSource{busSource{s.conn}, map[string]meshcatalogclient.Half{"main": {
Revision: catalogseat.CatalogRevision{Revision: 7, Definitions: []catalogseat.CatalogRevisionDefinitionsItem{{Seat: "mesh-catalog"}}},
Entries: map[string]catalogseat.CatalogEntry{"mesh-delivery-view": {Module: "mesh-delivery-view",
Manifest: map[string]any{"emits": []any{"delivered"}}}},
}}}, "desk.node-tools")
for _, c := range []struct {
name, says string
refused bool
}{
{"mesh-delivry-view.delivered", "mesh-delivry-view is neither a seat nor a module of the catalog half (mesh-catalog.revision, revision 7 of main)", true},
{"mesh-delivery-view.planned", "no declaration names mesh-delivery-view.planned", false},
{"mesh-catalog.catalog-revision", "", false},
} {
text, isErr := callWait(half, map[string]any{"subjects": []any{c.name}, "timeout": 1})
if isErr != c.refused || c.says != "" && !strings.Contains(text, c.says) ||
c.says == "" && (strings.Contains(text, "undeclared") || strings.Contains(text, "unchecked")) {
t.Errorf("%s against the catalog half: %s", c.name, text)
}
}
// The bus refusing every subscription of the call: answered after the timeout, in §16's words.
b, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true,
Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{
Subscribe: &server.SubjectPermission{Allow: []string{"_INBOX.desk.node-tools.>", "mesh.assignment.desk.*"}},
}}}})
if err != nil {
t.Fatal(err)
}
go b.Start()
if !b.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(b.Shutdown)
conn, err := bus.Connect(bus.Credential{URL: b.ClientURL(), Module: "node-tools", Node: "desk", User: "desk.node-tools", Password: "pw"})
if err != nil {
t.Fatal(err)
}
conn.Logf = func(string, ...any) {}
t.Cleanup(conn.Close)
start := time.Now()
text, isErr := callWait(NewSurface(conn, nil, "desk.node-tools"), map[string]any{"subjects": []any{"mesh-delivery-view.delivered"}, "timeout": 1})
if took := time.Since(start); took < time.Second || took > 3*time.Second {
t.Errorf("a wait the bus refused whole answered after %s, want its timeout of 1s", took)
}
if !isErr || !strings.Contains(text, "the bus refused this account a subscription on mesh.seat.mesh-delivery-view.event.delivered") ||
!strings.Contains(text, "novox/hq ADR 0670 §1") || strings.Contains(text, "wait grant") || strings.Contains(text, "0367 §2") {
t.Errorf("a wait the bus refused whole: %s", text)
}
}
// The mesh MCP server announces seven tools (ADR 0367 v13): ADR 0195's five, mesh_runtimes and mesh_wait, its timeout bounded.
func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) {
var names []string
var wait map[string]any
for _, tool := range discovery() {
names = append(names, tool["name"].(string))
if tool["name"] == verbWait {
wait = tool
}
}
sort.Strings(names)
want := []string{"mesh_call", "mesh_describe", "mesh_first_view_again", "mesh_first_views", "mesh_machine", "mesh_overview", "mesh_runtimes", "mesh_search", "mesh_wait"}
if !reflect.DeepEqual(names, want) {
t.Errorf("announced %v, want %v", names, want)
}
if wait == nil || !isDiscovery(verbWait) {
t.Fatal("mesh_wait is not announced")
}
schema, _ := json.Marshal(wait["inputSchema"])
for _, word := range []string{`"subjects"`, `"filter"`, `"timeout"`} {
if !strings.Contains(string(schema), word) {
t.Errorf("mesh_wait's schema has no %s: %s", word, schema)
}
}
}
// A client that hangs up or cancels ends its wait at once: the subscription is closed and nothing is left
// waiting for the timeout (the review of issue 745). Over the console's own HTTP, as an agent host calls it.
func TestMeshWaitEndsWhenItsClientHangsUp(t *testing.T) {
s, _ := waitingRuntime(t)
up, err := Serve(s, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = up.Close() })
before := s.conn.Subscriptions()
ctx, cancel := context.WithCancel(context.Background())
body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mesh_wait",` +
`"arguments":{"subjects":["mesh-knowledge.reviewed"],"timeout":600}}}`
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+up.Address+"/mcp", strings.NewReader(body))
req.Header.Set("content-type", "application/json")
go func() { _, _ = http.DefaultClient.Do(req) }()
mt.Until(t, func() error {
if s.conn.Subscriptions() <= before {
return os.ErrNotExist
}
return nil
})
cancel()
start := time.Now()
for s.conn.Subscriptions() > before {
if time.Since(start) > 3*time.Second {
t.Fatalf("%d subscriptions 3s after the client hung up, %d before the wait", s.conn.Subscriptions(), before)
}
time.Sleep(20 * time.Millisecond)
}
}
// The tool runner stopping ends every wait in flight at once (the second review of issue 745): Close cancels
// the requests' context rather than waiting up to ten minutes for a wait to time out.
func TestMeshWaitEndsWhenTheConsoleCloses(t *testing.T) {
s, _ := waitingRuntime(t)
up, err := Serve(s, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
before := s.conn.Subscriptions()
body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mesh_wait",` +
`"arguments":{"subjects":["mesh-knowledge.reviewed"],"timeout":600}}}`
go func() { _, _ = http.Post("http://"+up.Address+"/mcp", "application/json", strings.NewReader(body)) }()
mt.Until(t, func() error {
if s.conn.Subscriptions() <= before {
return os.ErrNotExist
}
return nil
})
closed := make(chan error, 1)
start := time.Now()
go func() { closed <- up.Close() }()
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("closing the console waited on a mesh_wait in flight")
}
if took := time.Since(start); took > 3*time.Second {
t.Errorf("closing took %s", took)
}
mt.Until(t, func() error {
if s.conn.Subscriptions() > before {
return os.ErrExist
}
return nil
})
}