End every wait in flight when the tool runner stops (issue 745)
mesh/merge-gate head 21d038d3397c merged into trunk 5d70eb3e35a9 — pass, with a note: builds mesh-tools, node-tools (+39 dependent(s)) → ace, g14, novox, s…
mesh/repo-check head 21d038d3397c merged into trunk 5d70eb3e35a9 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered

Closing the console shut its server down with a context that never ends, so a stop or restart of
the tool runner waited on any mesh_wait in flight for up to ten minutes. The server's base context
is now cancelled first, ending every wait at once, with a five-second shutdown as a backstop
(second review of issue 745).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 00:47:59 +00:00
co-authored by Claude Opus 5.5
parent 5d70eb3e35
commit 21d038d339
12 changed files with 775 additions and 18 deletions
+8
View File
@@ -76,6 +76,9 @@ type Membership struct {
// Hears are the module streams the module hears, each through a durable consumer on that stream the
// runtime binds beside the one on EVENTS (ADR 0363 §8).
Hears []HeardIssued `json:"hears,omitempty"`
// Waits are the event families a session may wait on through the mesh MCP server (novox/hq ADR 0367 §2):
// the tool runner's alone, composed from its manifest with the grant that lets it subscribe them.
Waits []WaitIssued `json:"waits,omitempty"`
}
// Served is an address a tool is answered on; `{tool}` stands for the tool's name.
@@ -181,6 +184,8 @@ type Conn struct {
RetryAfter []time.Duration
// calls are the caller-named calls in flight (caller.go, novox/hq issue 365).
calls callers
// waits are the waits in flight by subject, each told when the bus refuses its subscription (wait.go).
waits map[string]map[chan string]bool
}
// answered is one subject the runtime answers on: what to subscribe again with, and how often it was
@@ -218,6 +223,9 @@ func (c *Conn) refused(err error) {
if c.publishRefused(err) {
return
}
if subject := subjectRefused(err); subject != "" && c.waitRefused(subject) {
return
}
m := refusedSubject.FindStringSubmatch(err.Error())
if len(m) < 2 {
return
+132
View File
@@ -0,0 +1,132 @@
package bus
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
)
// Waiting on the bus for one event (novox/hq ADR 0367): what the mesh MCP server's `mesh_wait` does.
//
// **A subscription for the call, and nothing kept** (§2): no durable consumer, no queue, no stream. It
// subscribes the subjects when called and unsubscribes them before it answers, so an event published
// between two calls is not heard — a session reads the state from its owner after every wake (§4).
// WaitIssued is one event family the mesh issued this account to wait on (ADR 0367 §2): the name a session
// asks for, and the subject it lands on. Only the tool runner's membership carries any.
type WaitIssued struct {
Event string `json:"event"`
Subject string `json:"subject"`
}
// Heard is one event a wait heard: the subject it arrived on, its body and when it arrived.
type Heard struct {
Subject string
Body json.RawMessage
At time.Time
}
// HearFirst subscribes the subjects for this call alone and answers the first message whose body match
// accepts (every one, when match is nil), or ok false once `within` has passed. Every subscription is
// closed before it returns. A subscription the bus refuses is said as an error naming the subject, at once;
// a caller gone — ctx done — ends the wait at once with ctx's error, so nothing waits on for nobody.
func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (heard Heard, ok bool, err error) {
if len(subjects) == 0 {
return Heard{}, false, fmt.Errorf("a wait names at least one subject")
}
got := make(chan Heard, 1)
refused := make(chan string, len(subjects))
var subs []*nats.Subscription
defer func() {
for _, s := range subs {
_ = s.Unsubscribe()
}
c.mu.Lock()
for _, subject := range subjects {
delete(c.waits[subject], refused)
if len(c.waits[subject]) == 0 {
delete(c.waits, subject)
}
}
c.mu.Unlock()
}()
c.mu.Lock()
if c.waits == nil {
c.waits = map[string]map[chan string]bool{}
}
for _, subject := range subjects {
if c.waits[subject] == nil {
c.waits[subject] = map[chan string]bool{}
}
c.waits[subject][refused] = true
}
c.mu.Unlock()
for _, subject := range subjects {
sub, err := c.nc.Subscribe(subject, func(msg *nats.Msg) {
body := json.RawMessage(msg.Data)
if !json.Valid(body) {
quoted, _ := json.Marshal(string(msg.Data))
body = quoted
}
if match != nil && !match(body) {
return
}
select {
case got <- Heard{Subject: msg.Subject, Body: body, At: time.Now().UTC()}:
default:
}
})
if err != nil {
return Heard{}, false, fmt.Errorf("could not wait on %s: %w", subject, err)
}
subs = append(subs, sub)
}
// The bus has every subscription — or has said which it refused — once this returns.
if err := c.nc.FlushTimeout(5 * time.Second); err != nil {
return Heard{}, false, fmt.Errorf("the bus did not take the wait: %w", err)
}
timer := time.NewTimer(within)
defer timer.Stop()
select {
case h := <-got:
return h, true, nil
case subject := <-refused:
return Heard{}, false, fmt.Errorf("the bus refused this account a subscription on %s: it is not in the "+
"account's wait grant (novox/hq ADR 0367 §2)", subject)
case <-timer.C:
return Heard{}, false, nil
case <-ctx.Done():
return Heard{}, false, ctx.Err()
}
}
// waitRefused tells every wait on a subject the bus refused that it was refused; true when one was waiting.
func (c *Conn) waitRefused(subject string) bool {
c.mu.Lock()
defer c.mu.Unlock()
told := false
for ch := range c.waits[subject] {
select {
case ch <- subject:
default:
}
told = true
}
return told
}
// Subscriptions is how many subscriptions this connection holds now: what a wait leaves behind is none.
func (c *Conn) Subscriptions() int { return c.nc.NumSubscriptions() }
// subjectRefused is the subject a subscription refusal names, or "".
func subjectRefused(err error) string {
m := refusedSubject.FindStringSubmatch(err.Error())
if len(m) < 2 {
return ""
}
return strings.Trim(m[1], `"`)
}
+83
View File
@@ -0,0 +1,83 @@
package bus_test
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
// novox/hq ADR 0367 §2, §5: the bus refuses the tool runner's account any subject its wait grant does not
// name — another asker's `decided` among them — and a wait on one says the bus refused it, at once.
func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) {
granted := "mesh.seat.operator-channel.event.decided.claude-code"
s, 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{
Publish: &server.SubjectPermission{Allow: []string{"$JS.API.DIRECT.GET.>", "mesh.>"}},
Subscribe: &server.SubjectPermission{Allow: []string{granted, "_INBOX.desk.node-tools.>", "mesh.assignment.desk.*"}},
}}}})
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(s.Shutdown)
c := dial(t, s.ClientURL(), "node-tools", "desk", "desk.node-tools")
start := time.Now()
_, ok, err := c.HearFirst(context.Background(), []string{"mesh.seat.operator-channel.event.decided.shop"}, 5*time.Second, nil)
if ok || err == nil || !strings.Contains(err.Error(), "refused") {
t.Errorf("a wait on another asker's decided: heard %v, %v", ok, err)
}
if time.Since(start) > 3*time.Second {
t.Errorf("the refusal took %s to be said", time.Since(start))
}
done := make(chan bus.Heard, 1)
go func() {
h, ok, err := c.HearFirst(context.Background(), []string{granted}, 5*time.Second, func(body json.RawMessage) bool { return true })
if ok && err == nil {
done <- h
}
}()
time.Sleep(300 * time.Millisecond)
if err := c.Say(granted, []byte(`{"ask":"a1"}`)); err != nil {
t.Fatal(err)
}
select {
case h := <-done:
if h.Subject != granted || string(h.Body) != `{"ask":"a1"}` {
t.Errorf("heard %+v", h)
}
case <-time.After(5 * time.Second):
t.Fatal("a wait on the granted subject heard nothing")
}
}
// A wait whose caller is gone ends at once, its subscription closed (the review of issue 745).
func TestAWaitEndsWhenItsCallerIsGone(t *testing.T) {
c := dial(t, mt.URL(t), "node-tools", "desk", "")
before := c.Subscriptions()
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(200*time.Millisecond, cancel)
start := time.Now()
_, ok, err := c.HearFirst(ctx, []string{"mesh.mod.mesh-knowledge.event.reviewed"}, time.Minute, nil)
if ok || !errors.Is(err, context.Canceled) {
t.Errorf("a cancelled wait answered %v, %v", ok, err)
}
if took := time.Since(start); took > 2*time.Second {
t.Errorf("a cancelled wait ended after %s", took)
}
if after := c.Subscriptions(); after != before {
t.Errorf("%d subscriptions before, %d after", before, after)
}
}