0.1.13: one rule for taking an event, and no handler left by a refused subscribe (hq issue 276)

A handler takes an event by returning and asks for it again by throwing;
any mesh/event answer but an error is taken. Written down in the README
where event handling is, with what follows for a handler: throw only when
the work was not done, and be safe to run twice.

Both SDKs added the handler before asking the runtime and kept it when the
runtime refused, so a module that asks again until it is taken (every Go
consumer does) ran its handler once per attempt for every event. A refused
subscribe now removes its handler; one the Go runtime did not answer in
time is kept, since the runtime may have bound it.
This commit is contained in:
jochen
2026-10-06 18:14:58 +02:00
parent a5fc8308d4
commit ec80dcfc22
10 changed files with 223 additions and 11 deletions
+85
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"io"
"sync"
"testing"
"time"
)
@@ -139,3 +140,87 @@ func TestABundleSubscribesAndAsksThroughTheRuntime(t *testing.T) {
t.Error("topic matching differs from the TypeScript SDK's")
}
}
// A subscribe the runtime refused leaves no handler behind: asked again until the runtime takes it, a
// module's handler is handed each event once, not once per attempt (novox/hq issue 276).
func TestARefusedSubscribeLeavesNoHandlerBehind(t *testing.T) {
t.Setenv(ServedAs, "plex")
inR, inW := io.Pipe()
outR, outW := io.Pipe()
var mu sync.Mutex
handled := 0
subscribed := make(chan error, 2)
go func() {
_ = ServeOn("", []Tool{{Name: "start", Run: func(map[string]any) (any, error) {
go func() {
for i := 0; i < 2; i++ {
err := Subscribe("*.download.completed", func(Envelope) error {
mu.Lock()
handled++
mu.Unlock()
return nil
})
subscribed <- err
if err == nil {
return
}
}
}()
return "started", nil
}}}, inR, outW)
}()
lines := bufio.NewScanner(outR)
next := func() map[string]any {
done := make(chan map[string]any, 1)
go func() {
if lines.Scan() {
var m map[string]any
_ = json.Unmarshal(lines.Bytes(), &m)
done <- m
}
}()
select {
case m := <-done:
return m
case <-time.After(3 * time.Second):
t.Fatal("the bundle said nothing")
return nil
}
}
send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) }
send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "start"}})
answered := 0
for answered < 2 {
m := next()
switch m["method"] {
case "mesh/subscribe":
if answered == 0 {
send(map[string]any{"jsonrpc": "2.0", "id": m["id"], "error": map[string]any{"code": -32000, "message": "not yet"}})
} else {
send(map[string]any{"jsonrpc": "2.0", "id": m["id"], "result": map[string]any{}})
}
answered++
}
}
if err := <-subscribed; err == nil || err.Error() != "not yet" {
t.Fatalf("the refused subscribe said %v", err)
}
if err := <-subscribed; err != nil {
t.Fatalf("the second subscribe said %v", err)
}
send(map[string]any{"jsonrpc": "2.0", "id": 9, "method": "mesh/event", "params": map[string]any{"envelope": map[string]any{"key": "radarr.download.completed", "node": "anchor", "body": map[string]any{}}}})
for {
if r := next(); r["id"] == float64(9) {
if r["error"] != nil {
t.Fatalf("the event was answered %v", r)
}
break
}
}
_ = inW.Close()
mu.Lock()
defer mu.Unlock()
if handled != 1 {
t.Fatalf("one event was handled %d times", handled)
}
}