mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery delivered
callerOn reads a caller only under the call kind, so a tool subscription ending in a wildcard never yields one. A caller-named call that reached nobody is said once per subject before it is asked on the old subject, so a runtime or grant that drifted behind is seen rather than a quiet unproven. The caller tests wait for their bundle's exit, which reads the launcher's restart pauses another test sets.
213 lines
8.4 KiB
Go
213 lines
8.4 KiB
Go
package bus
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
)
|
|
|
|
// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks).
|
|
//
|
|
// mesh.mod.<module>.call.<tool>.<caller> to whichever instance answers
|
|
// mesh.mod.<module>.call.<tool>.<node>.<caller> to the instance on one machine
|
|
// mesh.seat.<seat>.call.<verb>.<caller> a mesh seat's verb
|
|
// mesh.seat.<seat>.call.<verb>.<node>.<caller> a node seat's verb on one machine
|
|
//
|
|
// The last token is the bus user that published the call, and the controller grants each user the publish of
|
|
// these subjects with its own name there and no other — so the caller is a fact the bus server enforces, and
|
|
// the runtime hands it to the module from the subject a call arrived on, never from the payload.
|
|
//
|
|
// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a
|
|
// wildcard over the `tool` kind — `mesh.mod.<m>.tool.<t>.*` for the instance on any machine, `mesh.mod.*.tool.>`
|
|
// for every tool — and either matches a `tool` subject with any caller appended, so a caller could name
|
|
// another. Under `call` no grant but the caller-named ones reaches the caller token.
|
|
//
|
|
// The `tool` subjects are served beside these for one release, and say Unproven, so callers move without a
|
|
// gap; their retirement is hq issue 464.
|
|
const CallKind = "call"
|
|
|
|
// A bus user's name carries dots (`<node>.<module>`, `person.<name>`); a subject token cannot. In the token
|
|
// each dot is written `~`, which no part of a bus user's name may hold (the controller refuses any part that
|
|
// is not letters, digits, `_` or `-`), so the token is the user's name and nothing else.
|
|
const userDot = "~"
|
|
|
|
var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
|
|
|
|
// CallerToken is a bus user's name as the last token of a call it publishes, or "" for a name that is not a
|
|
// bus user's.
|
|
func CallerToken(user string) string {
|
|
if !userName.MatchString(user) {
|
|
return ""
|
|
}
|
|
return strings.ReplaceAll(user, ".", userDot)
|
|
}
|
|
|
|
// UserOfToken is the bus user a caller token names, or "" when it names none.
|
|
func UserOfToken(token string) string {
|
|
user := strings.ReplaceAll(token, userDot, ".")
|
|
if !userName.MatchString(user) {
|
|
return ""
|
|
}
|
|
return user
|
|
}
|
|
|
|
// toolShape splits a tool subject — `mesh.mod.<m>.tool.<rest>` or `mesh.seat.<s>.tool.<rest>` — into what
|
|
// comes before the kind and what after; ok is false for any other subject.
|
|
func toolShape(subject string) (head, rest string, ok bool) {
|
|
parts := strings.SplitN(subject, ".", 5)
|
|
if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" ||
|
|
parts[2] == "" || parts[4] == "" {
|
|
return "", "", false
|
|
}
|
|
return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true
|
|
}
|
|
|
|
// CalledSubject is where a call to a tool subject goes naming its caller: the same address under the `call`
|
|
// kind, the caller's token last. "" for a subject that is not a tool's, or a caller that is not a bus user.
|
|
func CalledSubject(subject, user string) string {
|
|
head, rest, ok := toolShape(subject)
|
|
token := CallerToken(user)
|
|
if !ok || token == "" || strings.ContainsAny(rest, "*>") {
|
|
return ""
|
|
}
|
|
return head + "." + CallKind + "." + rest + "." + token
|
|
}
|
|
|
|
// calledPattern is what a runtime answering a tool subject also subscribes, to hear the calls that name their
|
|
// caller: the same address under the `call` kind, with any caller. "" for a subject that is not a tool's.
|
|
func calledPattern(subject string) string {
|
|
head, rest, ok := toolShape(subject)
|
|
if !ok || strings.ContainsAny(rest, "*>") {
|
|
return ""
|
|
}
|
|
return head + "." + CallKind + "." + rest + ".*"
|
|
}
|
|
|
|
// callerOn is the bus user a call names, from the subject it arrived on: its last token, under a pattern
|
|
// calledPattern made. "" — said by the module as Unproven — for a call on the subject that names no caller.
|
|
func callerOn(pattern, subject string) string {
|
|
// Only the `call` kind names a caller: a `tool` pattern ending in a wildcard (a node seat's verb on any
|
|
// machine) is never read as one.
|
|
parts := strings.SplitN(pattern, ".", 5)
|
|
if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != CallKind ||
|
|
!strings.HasSuffix(pattern, ".*") {
|
|
return ""
|
|
}
|
|
dot := strings.LastIndex(subject, ".")
|
|
if dot < 0 || subject[:dot] != strings.TrimSuffix(pattern, ".*") {
|
|
return ""
|
|
}
|
|
return UserOfToken(subject[dot+1:])
|
|
}
|
|
|
|
// CalledHandler answers one call, told who called: the bus user the subject named, or "" when it named none.
|
|
type CalledHandler func(caller string, body json.RawMessage) (any, error)
|
|
|
|
// callers is what a connection keeps of its caller-named calls: those in flight, by subject, so a refused
|
|
// publish ends its call at once instead of after RequestTimeout.
|
|
type callers struct {
|
|
mu sync.Mutex
|
|
// fellBack are the caller-named subjects already said to have reached nobody, so each is said once.
|
|
fellBack map[string]bool
|
|
next uint64
|
|
pending map[string]map[uint64]context.CancelCauseFunc
|
|
}
|
|
|
|
// errCallRefused is the bus refusing a caller-named publish: this user is not granted it yet (the controller's
|
|
// grant not delivered to the bus), so the call is asked again on the subject that names no caller.
|
|
var errCallRefused = errors.New("the bus refused the caller-named subject")
|
|
|
|
var refusedPublish = regexp.MustCompile(`(?i)Publish to "(\S+)"`)
|
|
|
|
// publishRefused ends every call in flight on a subject the bus refused this connection the publish of.
|
|
func (c *Conn) publishRefused(err error) bool {
|
|
if !errors.Is(err, nats.ErrPermissionViolation) {
|
|
return false
|
|
}
|
|
m := refusedPublish.FindStringSubmatch(err.Error())
|
|
if len(m) < 2 {
|
|
return false
|
|
}
|
|
c.calls.mu.Lock()
|
|
cancels := c.calls.pending[m[1]]
|
|
delete(c.calls.pending, m[1])
|
|
c.calls.mu.Unlock()
|
|
if len(cancels) > 0 {
|
|
c.Logf("[mesh-tools] the bus refused this account the caller-named %s; asked again on the subject that "+
|
|
"names no caller, until the mesh grants it (novox/hq issue 365)", m[1])
|
|
}
|
|
for _, cancel := range cancels {
|
|
cancel(errCallRefused)
|
|
}
|
|
return len(cancels) > 0
|
|
}
|
|
|
|
// askCalled asks once on a caller-named subject. fallBack says the call reached nobody — nothing serves that
|
|
// subject yet, or the bus refused this user its publish — so it may be asked on the subject that names no
|
|
// caller without being done twice.
|
|
func (c *Conn) askCalled(subject string, data []byte) (answered Answered, handingOver, fallBack bool, err error) {
|
|
ctx, cancel := context.WithCancelCause(context.Background())
|
|
ctx, stop := context.WithTimeout(ctx, RequestTimeout)
|
|
defer stop()
|
|
c.calls.mu.Lock()
|
|
if c.calls.pending == nil {
|
|
c.calls.pending = map[string]map[uint64]context.CancelCauseFunc{}
|
|
}
|
|
if c.calls.pending[subject] == nil {
|
|
c.calls.pending[subject] = map[uint64]context.CancelCauseFunc{}
|
|
}
|
|
c.calls.next++
|
|
id := c.calls.next
|
|
c.calls.pending[subject][id] = cancel
|
|
c.calls.mu.Unlock()
|
|
defer func() {
|
|
c.calls.mu.Lock()
|
|
if list := c.calls.pending[subject]; list != nil {
|
|
delete(list, id)
|
|
if len(list) == 0 {
|
|
delete(c.calls.pending, subject)
|
|
}
|
|
}
|
|
c.calls.mu.Unlock()
|
|
cancel(nil)
|
|
}()
|
|
msg, err := c.nc.RequestWithContext(ctx, subject, data)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(context.Cause(ctx), errCallRefused):
|
|
return Answered{}, false, true, nil
|
|
case errors.Is(err, nats.ErrNoResponders):
|
|
c.saidFallBack(subject)
|
|
return Answered{}, false, true, nil
|
|
case errors.Is(err, context.DeadlineExceeded) || errors.Is(err, nats.ErrTimeout):
|
|
return Answered{}, false, false, errors.New("timeout")
|
|
}
|
|
return Answered{}, false, false, err
|
|
}
|
|
answered, handingOver, err = c.answerOf(subject, msg.Data)
|
|
return answered, handingOver, false, err
|
|
}
|
|
|
|
// saidFallBack says, once per subject, that a caller-named call reached nobody and is asked on the subject that
|
|
// names no caller: a runtime or holder not yet serving it, or a grant that drifted, is seen in the log rather
|
|
// than read later as a quiet "unproven" (novox/hq issue 365; expected for the controller's seat until hq issue
|
|
// 464).
|
|
func (c *Conn) saidFallBack(subject string) {
|
|
c.calls.mu.Lock()
|
|
if c.calls.fellBack == nil {
|
|
c.calls.fellBack = map[string]bool{}
|
|
}
|
|
first := !c.calls.fellBack[subject]
|
|
c.calls.fellBack[subject] = true
|
|
c.calls.mu.Unlock()
|
|
if first {
|
|
c.Logf("[mesh-tools] nothing answers the caller-named %s; asked on the subject that names no caller, where "+
|
|
"the module is told no caller (said once per subject; novox/hq issue 365)", subject)
|
|
}
|
|
}
|