Bind a stream consumer again when the bus ends its loop, so a module is not left deaf when the controller deletes and makes again a consumer it cannot narrow (hq issue 725)
mesh/merge-gate head d3b0330ed1cb merged into trunk d0dcae53a8c0 — pass, with a note: builds mesh-tools, node-tools (+39 dependent(s)) → ace, g14, novox, s…
mesh/repo-check head d3b0330ed1cb merged into trunk d0dcae53a8c0 — pass: its merge-check.sh passed
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792008849869252364 (novox/mesh-tools at 5d70eb3e into main, d…

This commit is contained in:
jochen
2026-10-14 22:10:20 +02:00
parent 9921486fd5
commit d3b0330ed1
4 changed files with 188 additions and 16 deletions
+82
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
@@ -196,3 +197,84 @@ func TestAConsumerNamedBeforeItExistsIsBoundOnceItDoes(t *testing.T) {
return nil
})
}
// novox/hq ADR 0363 §6 (issue 725): a consumer the controller deletes and makes again under the same name, its
// membership unchanged, is bound again, and what is published after is heard — the module does not go deaf.
func TestAConsumerDeletedAndMadeAgainIsHeardAgain(t *testing.T) {
restore := bus.RebindEvery
bus.RebindEvery = 200 * time.Millisecond
t.Cleanup(func() { bus.RebindEvery = restore })
reader := mt.MembershipOf("reader", "anchor", false, nil)
reader.Hears = []bus.HeardIssued{{Stream: "MAIL", Consumer: "anchor_reader"}}
mesh, conn := streamMesh(t, reader)
mesh.Consumer(t, "anchor", "reader", []string{"mesh.mod.alpha.event.>"}, 5*time.Second)
mesh.ConsumerOn(t, "MAIL", "anchor", "reader", []string{"mesh.stream.mail.event.>"}, 5*time.Second)
logs := &syncLogs{}
conn.Logf = logs.Logf
var mu sync.Mutex
heard := 0
stop, err := conn.ConsumeAs("reader", func(env bus.Envelope) error {
if env.Key == "mail.mail.home.message.added" {
mu.Lock()
heard++
mu.Unlock()
}
return nil
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(stop)
count := func() int {
mu.Lock()
defer mu.Unlock()
return heard
}
mesh.Emit(t, "mesh.stream.mail.event.mail.home.message.added", map[string]any{"n": 1})
mt.Until(t, func() error {
if count() != 1 {
return errors.New("the first event on MAIL was not heard")
}
return nil
})
mesh.RemoveConsumer(t, "MAIL", "anchor", "reader")
mt.Until(t, func() error {
if !logs.Has("stopped hearing MAIL") {
return errors.New("the end of the loop was not said")
}
return nil
})
mesh.ConsumerOn(t, "MAIL", "anchor", "reader", []string{"mesh.stream.mail.event.>"}, 5*time.Second)
mesh.Emit(t, "mesh.stream.mail.event.mail.home.message.added", map[string]any{"n": 2})
mt.Until(t, func() error {
if count() != 2 {
return fmt.Errorf("heard %d events on MAIL after its consumer was made again, want 2", count())
}
return nil
})
}
// syncLogs keeps what the runtime says, from any goroutine.
type syncLogs struct {
mu sync.Mutex
lines []string
}
func (l *syncLogs) Logf(format string, args ...any) {
l.mu.Lock()
defer l.mu.Unlock()
l.lines = append(l.lines, fmt.Sprintf(format, args...))
}
func (l *syncLogs) Has(fragment string) bool {
l.mu.Lock()
defer l.mu.Unlock()
for _, line := range l.lines {
if strings.Contains(line, fragment) {
return true
}
}
return false
}