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..call.. to whichever instance answers // mesh.mod..call... to the instance on one machine // mesh.seat..call.. a mesh seat's verb // mesh.seat..call... 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..tool..*` 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 (`.`, `person.`); 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..tool.` or `mesh.seat..tool.` — 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) } }