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:
@@ -0,0 +1,108 @@
|
||||
// Package calladdress reads the key a module calls a tool or a seat verb by, as a module's invokes names it, with the
|
||||
// machine it is asked of after '@': `seat:<seat>.<verb>[@<node>]` or `<module>.<tool>[@<node>]` (novox/hq ADR 0159,
|
||||
// ADR 0469 §3). Every caller in the SDK parses its key here before it publishes anything, so a malformed key fails at
|
||||
// once and says why, never as a call nobody serves and a timeout (novox/hq issue 774, the lease write's stop asked on
|
||||
// `...call.stop@novox-be...`).
|
||||
package calladdress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address is a parsed key: a seat and its verb, or a module and its tool, and the machine named, if any.
|
||||
type Address struct {
|
||||
Seat, Verb string
|
||||
Module, Tool string
|
||||
Node string
|
||||
}
|
||||
|
||||
// Key is the address as a module's invokes names it, without the machine.
|
||||
func (a Address) Key() string {
|
||||
if a.Seat != "" {
|
||||
return "seat:" + a.Seat + "." + a.Verb
|
||||
}
|
||||
return a.Module + "." + a.Tool
|
||||
}
|
||||
|
||||
// Parse reads a key, refusing one with more than one '@', a node that is not one subject token, or a seat, verb, module
|
||||
// or tool that holds a wildcard, white space or (but a tool) a dot.
|
||||
func Parse(key string) (Address, error) {
|
||||
name, node, err := cutNode(key)
|
||||
if err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(name, "seat:"); ok {
|
||||
seat, verb, ok := strings.Cut(rest, ".")
|
||||
if !ok || seat == "" || verb == "" {
|
||||
return Address{}, fmt.Errorf("%q names no seat and verb (seat:<seat>.<verb>)", key)
|
||||
}
|
||||
if err := tokens(key, seat, verb); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
return Address{Seat: seat, Verb: verb, Node: node}, nil
|
||||
}
|
||||
module, tool, ok := strings.Cut(name, ".")
|
||||
if !ok || module == "" || tool == "" {
|
||||
return Address{}, fmt.Errorf("%q is not <module>.<tool>, and does not start seat:<seat>.<verb>", key)
|
||||
}
|
||||
if err := tokens(key, module); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
if err := checkTool(key, tool); err != nil {
|
||||
return Address{}, err
|
||||
}
|
||||
return Address{Module: module, Tool: tool, Node: node}, nil
|
||||
}
|
||||
|
||||
// CheckOwn is Parse for a caller that may also name a tool of its own module by its bare name (the runtime's mesh/ask).
|
||||
func CheckOwn(key string) error {
|
||||
name, _, err := cutNode(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(name, "seat:") && !strings.Contains(name, ".") {
|
||||
return tokens(key, name)
|
||||
}
|
||||
_, err = Parse(key)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func cutNode(key string) (name, node string, err error) {
|
||||
name, node, at := strings.Cut(key, "@")
|
||||
if !at {
|
||||
return key, "", nil
|
||||
}
|
||||
if strings.Contains(node, "@") {
|
||||
return "", "", fmt.Errorf("%q holds more than one '@': a key names at most one machine", key)
|
||||
}
|
||||
if err := CheckNode(node); err != nil {
|
||||
return "", "", fmt.Errorf("%q: %w", key, err)
|
||||
}
|
||||
return name, node, nil
|
||||
}
|
||||
|
||||
func tokens(key string, parts ...string) error {
|
||||
for _, p := range parts {
|
||||
if p == "" || strings.ContainsAny(p, ".*> \t\r\n") {
|
||||
return fmt.Errorf("%q: %q is not one subject token (no '.', '*', '>' or space)", key, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTool refuses a tool name with a wildcard or white space; a dot is kept, as the tool runner serves it.
|
||||
func checkTool(key, tool string) error {
|
||||
if strings.ContainsAny(tool, "*> \t\r\n") || strings.HasPrefix(tool, ".") || strings.HasSuffix(tool, ".") {
|
||||
return fmt.Errorf("%q: %q is not one subject token (no '*', '>' or space)", key, tool)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package calladdress
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 774 notes 84-87 (the lease write's stop asked of `seat:node-service-manager.stop@novox-be` published on a subject
|
||||
// nobody serves and timed out after 30 s): a key names its machine after '@', and every other malformed key is refused
|
||||
// before anything is published, naming why.
|
||||
func TestAKeyIsParsedOrRefusedBeforeItIsPublished(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
key, want string // want: "seat|<seat>|<verb>|<node>", "mod|<module>|<tool>|<node>", or a refusal's words
|
||||
}{
|
||||
{"seat:node-service-manager.stop@novox", "seat|node-service-manager|stop|novox"},
|
||||
{"seat:operator-channel.tell", "seat|operator-channel|tell|"},
|
||||
{"mesh-build-go.fetch@ace", "mod|mesh-build-go|fetch|ace"},
|
||||
{"nats.nats_users", "mod|nats|nats_users|"},
|
||||
{"seat:node-service-manager.stop@novox@g14", "more than one '@'"},
|
||||
{"seat:node-service-manager.stop@", "not a node name"},
|
||||
{"seat:node-service-manager.stop@nov.ox", "not a node name"},
|
||||
{"seat:node-files.*", "not one subject token"},
|
||||
{"mesh-build-go.fe tch", "not one subject token"},
|
||||
{"mesh>build.fetch", "not one subject token"},
|
||||
{"seat:noverb", "names no seat and verb"},
|
||||
{"nodot", "is not <module>.<tool>"},
|
||||
{"", "is not <module>.<tool>"},
|
||||
} {
|
||||
a, err := Parse(row.key)
|
||||
got := ""
|
||||
switch {
|
||||
case err != nil:
|
||||
got = err.Error()
|
||||
if !strings.Contains(got, row.want) {
|
||||
t.Errorf("Parse(%q) refused %q, want %q", row.key, got, row.want)
|
||||
}
|
||||
continue
|
||||
case a.Seat != "":
|
||||
got = "seat|" + a.Seat + "|" + a.Verb + "|" + a.Node
|
||||
default:
|
||||
got = "mod|" + a.Module + "|" + a.Tool + "|" + a.Node
|
||||
}
|
||||
if got != row.want {
|
||||
t.Errorf("Parse(%q) = %s, want %s", row.key, got, row.want)
|
||||
}
|
||||
}
|
||||
if a, err := Parse("seat:node-files.read@g14"); err != nil || a.Key() != "seat:node-files.read" {
|
||||
t.Errorf("the key without its machine: %q, %v", a.Key(), err)
|
||||
}
|
||||
if err := CheckOwn("list@ace"); err != nil {
|
||||
t.Errorf("a module's own tool by its bare name was refused: %v", err)
|
||||
}
|
||||
if err := CheckOwn("li*st"); err == nil {
|
||||
t.Error("a bare name that is not one token was accepted")
|
||||
}
|
||||
}
|
||||
+21
-24
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+7
-1
@@ -24,6 +24,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/calladdress"
|
||||
)
|
||||
|
||||
// Protocol is the MCP version this speaks; the runtime says the same.
|
||||
@@ -471,8 +473,12 @@ func Subscribe(pattern string, handle func(Envelope) error) error {
|
||||
}
|
||||
|
||||
// Ask calls a tool on the mesh as this bundle's module, through the runtime (ADR 0198), and returns
|
||||
// its result as JSON. The key is `<module>.<tool>` as the runtime calls it.
|
||||
// its result as JSON. The key is `<module>.<tool>` or `seat:<seat>.<verb>`, with `@<node>` for one machine's, as the
|
||||
// runtime calls it; a malformed key is refused here, before the runtime is asked (calladdress; novox/hq issue 774).
|
||||
func Ask(key string, body any) (json.RawMessage, error) {
|
||||
if err := calladdress.CheckOwn(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := live()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -160,6 +161,12 @@ func TestABundleSubscribesAndAsksThroughTheRuntime(t *testing.T) {
|
||||
if !TopicMatches("a.#", "a.b.c") || TopicMatches("a.*", "a.b.c") || !TopicMatches("#", "x") {
|
||||
t.Error("topic matching differs from the TypeScript SDK's")
|
||||
}
|
||||
// Issue 774 notes 84-87: a malformed key is refused before the runtime is asked.
|
||||
for _, key := range []string{"seat:node-service-manager.stop@novox@g14", "seat:a.*", "postgres.list@", "postgres.list@a.b"} {
|
||||
if _, err := Ask(key, nil); err == nil || strings.Contains(err.Error(), "Serve") {
|
||||
t.Errorf("Ask(%q) = %v, want the key refused", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A subscribe the runtime refused leaves no handler behind: asked again until the runtime takes it, a
|
||||
|
||||
Reference in New Issue
Block a user