Route a call key naming its machine after '@', and refuse a malformed key before publishing

The lease write asked seat:node-service-manager.stop@novox-be through Conn.Call, which published
the '@' inside the subject: nobody served it and the call timed out after 30 s with nothing said
(issue 774 notes 84-87). Every key now parses in one place, calladdress: Call routes '@node' as
CallOn does, and stdio Ask refuses a key the runtime could not route, before anything is sent.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 11:22:50 +01:00
parent 8295207077
commit 49b307a46d
7 changed files with 219 additions and 27 deletions
+21 -24
View File
@@ -11,6 +11,7 @@ import (
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-sdk/go/calladdress"
)
// CallTimeout bounds a call to a tool or a seat verb (mesh-tools node-tools/internal/bus/bus.go:35,
@@ -24,9 +25,10 @@ var CallTimeout = 30 * time.Second
// verb, or "<module>.<tool>" for a module's own tool, as a process's bus.invokes names it. The reply is
// {result, error, node} (mesh-tools node-tools/internal/bus/bus.go answerOf); a non-empty error is the
// call's failure. It offers no way to answer a call — a process reaches the bus only as itself, and
// never serves anything.
// never serves anything. A key naming its machine after '@' (`seat:node-service-manager.stop@novox`) is CallOn of that
// machine; any other malformed key is refused before anything is published (calladdress; novox/hq issue 774).
func (c *Conn) Call(key string, body any) (json.RawMessage, error) {
called, fallback, err := callSubjects(key, c.callerToken())
called, fallback, err := callSubjectsOn(key, "", c.callerToken())
if err != nil {
return nil, err
}
@@ -39,7 +41,7 @@ func (c *Conn) Call(key string, body any) (json.RawMessage, error) {
// (mesh-tools node-tools/internal/bus/caller.go:15-19). node is one subject token: an empty one, or one
// holding '.', '*', '>' or white space, is refused before anything is published.
func (c *Conn) CallOn(node, key string, body any) (json.RawMessage, error) {
if err := checkNode(node); err != nil {
if err := calladdress.CheckNode(node); err != nil {
return nil, err
}
called, fallback, err := callSubjectsOn(key, node, c.callerToken())
@@ -99,33 +101,28 @@ func callSubjects(key, token string) (called, fallback string, err error) {
return callSubjectsOn(key, "", token)
}
// checkNode refuses a node name that is not one subject token.
func checkNode(node string) error {
if node == "" || strings.ContainsAny(node, ".*> \t\r\n") {
return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>' or space)", node)
}
return nil
}
// callSubjectsOn is callSubjects for one named node ("" names none): the node is a token between the tool and
// the caller on the called subject, and the last one of the fallback.
// callSubjectsOn is callSubjects for one named node ("" names none, and then the key's own '@' names it): the node is a
// token between the tool and the caller on the called subject, and the last one of the fallback. A key naming another
// machine than node is refused.
func callSubjectsOn(key, node, token string) (called, fallback string, err error) {
a, err := calladdress.Parse(key)
if err != nil {
return "", "", err
}
switch {
case a.Node != "" && node != "" && a.Node != node:
return "", "", fmt.Errorf("%q names the machine %s, and it is asked of %s", key, a.Node, node)
case a.Node != "":
node = a.Node
}
at := ""
if node != "" {
at = "." + node
}
if rest, ok := strings.CutPrefix(key, "seat:"); ok {
seat, verb, ok := strings.Cut(rest, ".")
if !ok || seat == "" || verb == "" {
return "", "", fmt.Errorf("%q names no seat and verb (seat:<seat>.<verb>)", key)
}
return "mesh.seat." + seat + ".call." + verb + at + "." + token, "mesh.seat." + seat + ".tool." + verb + at, nil
if a.Seat != "" {
return "mesh.seat." + a.Seat + ".call." + a.Verb + at + "." + token, "mesh.seat." + a.Seat + ".tool." + a.Verb + at, nil
}
module, tool, ok := strings.Cut(key, ".")
if !ok || module == "" || tool == "" {
return "", "", fmt.Errorf("%q is not <module>.<tool>, and does not start seat:<seat>.<verb>", key)
}
return "mesh.mod." + module + ".call." + tool + at + "." + token, "mesh.mod." + module + ".tool." + tool + at, nil
return "mesh.mod." + a.Module + ".call." + a.Tool + at + "." + token, "mesh.mod." + a.Module + ".tool." + a.Tool + at, nil
}
// request asks one subject once and reads its reply ({result, error, node}); a reply naming an error is
+5 -1
View File
@@ -237,7 +237,8 @@ func TestReleaseAcceptanceAProcessDoesExactlyItsBusDeclaration(t *testing.T) {
}
// 1470/AC4: two answerers of the same verb on the subjects of nodes ace and g14; CallOn naming g14 gets g14's
// answer, and ace's answerer hears nothing.
// answer, and ace's answerer hears nothing. Issue 774 notes 84-87: Call with the key `<verb>@g14` is published on the
// same subject, never on one holding the '@' that nobody serves.
func TestCallOnReachesTheNamedNodeAlone(t *testing.T) {
url, account := grantedSendProcess(t)
adminNC, _ := adminJS(t, url)
@@ -271,6 +272,9 @@ func TestCallOnReachesTheNamedNodeAlone(t *testing.T) {
if string(result) != `{"node":"g14"}` {
t.Fatalf("result: %s, want g14's answer", result)
}
if result, err = conn.Call("seat:operator-channel.tell@g14", map[string]any{}); err != nil || string(result) != `{"node":"g14"}` {
t.Fatalf("Call with @g14: %s, %v, want g14's answer", result, err)
}
select {
case <-aceHeard:
t.Error("ace's answerer heard a call named for g14")
+15 -1
View File
@@ -73,17 +73,31 @@ func TestCallSubjectsOnANodeForASeatVerb(t *testing.T) {
if called != "mesh.seat.node-files.call.read.g14.tok" || fallback != "mesh.seat.node-files.tool.read.g14" {
t.Errorf("called %q, fallback %q", called, fallback)
}
// Issue 774 notes 84-87: the machine named in the key after '@' is the same subject, never a token holding '@'.
called, fallback, err = callSubjects("seat:node-files.read@g14", "tok")
if err != nil || called != "mesh.seat.node-files.call.read.g14.tok" || fallback != "mesh.seat.node-files.tool.read.g14" {
t.Errorf("@g14: called %q, fallback %q, %v", called, fallback, err)
}
if _, _, err := callSubjectsOn("seat:node-files.read@ace", "g14", "tok"); err == nil {
t.Error("a key naming ace was asked of g14")
}
}
// 1470/AC3: a node that is not one token is refused before anything is published (the Conn has no
// connection, so only the name check can answer with this error).
func TestCallOnRefusesANodeThatIsNotOneToken(t *testing.T) {
c := &Conn{}
for _, node := range []string{"", "a.b", "*", ">", "a b", "a\tb"} {
for _, node := range []string{"", "a.b", "*", ">", "a b", "a\tb", "a@b"} {
if _, err := c.CallOn(node, "mesh-build-go.fetch", nil); err == nil || !strings.Contains(err.Error(), "not a node name") {
t.Errorf("CallOn(%q) = %v, want the refusal of the name", node, err)
}
}
// Issue 774 notes 84-87: Call refuses a malformed key the same way, before anything is published.
for _, key := range []string{"seat:node-service-manager.stop@novox-be.lan", "seat:a.b@x@y", "seat:a.*", "a.b@"} {
if _, err := c.Call(key, nil); err == nil {
t.Errorf("Call(%q) was published", key)
}
}
}
// The review of 2026-10-30 (issue 774): a callee's own answer is delivered, whatever its words, and is never asked