32 Commits

Author SHA1 Message Date
Jay df0a968907 add logger to auth manager 2026-06-04 16:03:10 -04:00
Jay dda3bfd5b7 cleanup: flakey tests and race conditions 2026-06-04 12:07:03 -04:00
Jay b5b0ec77ba chore: bump go-honeywood to v0.5.1 2026-06-03 23:26:51 -04:00
Jay c11e40b48a chore: bump go-mana-component to v0.2.0, go-roots-ws to v0.2.0 2026-06-03 23:23:54 -04:00
Jay a8c57aaeac test: assert no side effects for unknown OK in EventPublisher 2026-06-03 23:08:05 -04:00
Jay 5e425337ff test: assert NoticeReceived.At is non-zero in observer notified 2026-06-03 23:05:12 -04:00
Jay 21a1112811 test: RequestManager observable assertions and ReqSendFailed subtest 2026-06-03 22:58:15 -04:00
Jay c8024e2b98 test: EventPublisher unknown OK ignored 2026-06-03 22:30:00 -04:00
Jay 81875b3a8d test: EventPublisher close cancels pending 2026-06-03 22:28:04 -04:00
Jay 00460030ae test: EventPublisher disconnect then send failure on reconnect 2026-06-03 22:25:18 -04:00
Jay 148850663c test: EventPublisher send failure delivers error 2026-06-03 22:23:14 -04:00
Jay 1bdf9e0475 test: EventPublisher resend after disconnect 2026-06-03 22:20:04 -04:00
Jay 3f8574508d test: EventPublisher held event sent on connect 2026-06-03 22:17:40 -04:00
Jay 963654cae8 test: EventPublisher publish while disconnected times out 2026-06-03 22:14:51 -04:00
Jay a66d8386dd test: EventPublisher concurrent correlation 2026-06-03 22:13:25 -04:00
Jay 5051ba7fed test: EventPublisher publish accepted/rejected/timeout with observer assertions 2026-06-03 18:29:51 -04:00
Jay e6f23759bc test: EventPublisher publish rejected 2026-06-03 18:10:43 -04:00
Jay dae4c7773d test: EventPublisher publish accepted 2026-06-03 18:07:28 -04:00
Jay cb848e3f32 chore: add AGENTS.md with go workspace note 2026-06-03 16:38:33 -04:00
Jay f8b45ae068 test: AuthManager close cleans up 2026-06-03 16:34:59 -04:00
Jay 783171e6ef test: AuthManager malformed AUTH ignored 2026-06-03 16:32:38 -04:00
Jay b6846c25bb test: AuthManager replacement challenge 2026-06-03 16:31:22 -04:00
Jay e16aa908bf test: AuthManager disconnect resets state 2026-06-03 16:28:45 -04:00
Jay f9d9dfac56 test: AuthManager send error recorded 2026-06-03 16:27:13 -04:00
Jay 227f104077 test: AuthManager callback error recorded 2026-06-03 16:25:58 -04:00
Jay 51148a8a91 test: AuthManager challenge triggers callback and send 2026-06-03 15:25:52 -04:00
Jay a3f2c9b2d8 test: NoticeHandler close cleans up 2026-06-03 15:20:37 -04:00
Jay cf4a2d380a test: NoticeHandler observer notified 2026-06-03 15:19:42 -04:00
Jay 9d4873f8d4 test: NoticeHandler malformed ignored 2026-06-03 15:18:10 -04:00
Jay ee0f5953da test: NoticeHandler notice delivered 2026-06-03 15:17:03 -04:00
Jay e360f35ee5 prepare code skeleton to develop remaining managers 2026-06-03 15:04:08 -04:00
Jay 29f6966ae0 use options 2026-06-03 11:42:55 -04:00
13 changed files with 1392 additions and 60 deletions
+4
View File
@@ -0,0 +1,4 @@
# go-mana-prism
## Build
- This repo is part of a Go workspace at `../go.work`; resolve local packages from there before searching elsewhere.
+123
View File
@@ -0,0 +1,123 @@
package prism
import (
"context"
"git.wisehodl.dev/jay/go-mana-component"
"log/slog"
"sync"
"time"
"git.wisehodl.dev/jay/go-roots-ws"
)
type ChallengeReceived struct {
Challenge string
At time.Time
}
type AuthResponseSent struct {
At time.Time
}
type AuthResponseFailed struct {
Err error
At time.Time
}
type AuthManager struct {
envoy *Envoy
respond func(challenge string) ([]byte, error)
challenge string
inbox <-chan InboxMessage
events <-chan OutboundPoolEvent
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
wg sync.WaitGroup
handler slog.Handler
logger *slog.Logger
}
func NewAuthManager(e *Envoy, respond func(string) ([]byte, error)) *AuthManager {
ctx, cancel := context.WithCancel(
component.MustExtend(e.Context(), "auth_manager"))
m := &AuthManager{
envoy: e,
respond: respond,
inbox: e.SubscribeInbox([]string{"AUTH"}),
events: e.SubscribeEvents(),
ctx: ctx,
cancel: cancel,
}
if e.Handler() != nil {
comp := component.FromContext(ctx)
m.handler = e.Handler()
m.logger = slog.New(m.handler).With(slog.Any("component", comp))
}
m.wg.Go(m.routeInbox)
m.wg.Go(m.handleEvents)
return m
}
func (m *AuthManager) Close() {
m.cancel()
m.wg.Wait()
}
func (m *AuthManager) routeInbox() {
for {
select {
case <-m.ctx.Done():
return
case msg := <-m.inbox:
challenge, err := envelope.FindAuthChallenge(msg.Data)
if err != nil {
continue
}
m.mu.Lock()
m.challenge = challenge
m.mu.Unlock()
now := time.Now()
m.envoy.Observer().Record(msg.ID, ChallengeReceived{Challenge: challenge, At: now})
signed, err := m.respond(challenge)
if err != nil {
m.envoy.Observer().Record(msg.ID, AuthResponseFailed{Err: err, At: time.Now()})
continue
}
if err := m.envoy.Send([]byte(envelope.EncloseAuthResponse(signed))); err != nil {
m.envoy.Observer().Record(msg.ID, AuthResponseFailed{Err: err, At: time.Now()})
continue
}
if m.logger != nil {
m.logger.Info("responded to auth", "challenge", challenge)
}
m.envoy.Observer().Record(msg.ID, AuthResponseSent{At: time.Now()})
}
}
}
func (m *AuthManager) handleEvents() {
for {
select {
case <-m.ctx.Done():
return
case ev := <-m.events:
if ev.Kind == EventDisconnected {
m.mu.Lock()
m.challenge = ""
m.mu.Unlock()
}
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package prism
import (
"bytes"
"errors"
"sync"
"testing"
"git.wisehodl.dev/jay/go-roots-ws"
"github.com/stretchr/testify/assert"
)
func TestAuthManager(t *testing.T) {
t.Run("challenge triggers callback and send", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
signed := []byte(`{"kind":22242}`)
var mu sync.Mutex
var calledWith string
callback := func(challenge string) ([]byte, error) {
mu.Lock()
defer mu.Unlock()
calledWith = challenge
return signed, nil
}
m := NewAuthManager(envoy, callback)
t.Cleanup(m.Close)
p.connect()
p.receive([]byte(envelope.EncloseAuthChallenge("abc")))
Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return calledWith == "abc"
},
"callback not called with challenge")
Eventually(t, func() bool {
select {
case got := <-p.sent:
return bytes.Contains(got, signed)
default:
return false
}
}, "AUTH response envelope not sent")
assert.Len(t, EventsOf[ChallengeReceived](obs), 1)
assert.Len(t, EventsOf[AuthResponseSent](obs), 1)
})
t.Run("callback error recorded", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
callback := func(challenge string) ([]byte, error) {
return nil, errors.New("signing failed")
}
m := NewAuthManager(envoy, callback)
t.Cleanup(m.Close)
p.connect()
p.receive([]byte(envelope.EncloseAuthChallenge("abc")))
Eventually(t, func() bool {
return len(EventsOf[AuthResponseFailed](obs)) == 1
}, "AuthResponseFailed not recorded")
Never(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "no AUTH envelope should be sent on callback error")
assert.Len(t, EventsOf[ChallengeReceived](obs), 1)
})
t.Run("send error recorded", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
callback := func(challenge string) ([]byte, error) {
return []byte(`{"kind":22242}`), nil
}
m := NewAuthManager(envoy, callback)
t.Cleanup(m.Close)
p.setSendError(errors.New("send failed"))
p.connect()
p.receive([]byte(envelope.EncloseAuthChallenge("abc")))
Eventually(t, func() bool {
return len(EventsOf[AuthResponseFailed](obs)) == 1
}, "AuthResponseFailed not recorded")
})
t.Run("disconnect resets state", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
var mu sync.Mutex
var calls []string
callback := func(challenge string) ([]byte, error) {
mu.Lock()
calls = append(calls, challenge)
mu.Unlock()
return []byte(`{"kind":22242}`), nil
}
m := NewAuthManager(envoy, callback)
t.Cleanup(m.Close)
p.connect()
p.receive([]byte(envelope.EncloseAuthChallenge("abc")))
Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(calls) == 1 && calls[0] == "abc"
}, "first callback not fired")
p.disconnect()
// drain the sent channel so p.sent doesn't block the second send
select {
case <-p.sent:
default:
}
p.connect()
p.receive([]byte(envelope.EncloseAuthChallenge("def")))
Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(calls) == 2 && calls[1] == "def"
}, "second callback not fired with new challenge")
})
t.Run("replacement challenge", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
var mu sync.Mutex
var calls []string
callback := func(challenge string) ([]byte, error) {
mu.Lock()
calls = append(calls, challenge)
mu.Unlock()
return []byte(`{"kind":22242}`), nil
}
m := NewAuthManager(envoy, callback)
t.Cleanup(m.Close)
p.connect()
p.receive([]byte(envelope.EncloseAuthChallenge("abc")))
p.receive([]byte(envelope.EncloseAuthChallenge("def")))
Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(calls) == 2 && calls[1] == "def"
}, "callback not called twice with both challenges")
assert.GreaterOrEqual(t, len(p.sent), 2)
})
t.Run("malformed AUTH ignored", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
called := false
callback := func(challenge string) ([]byte, error) {
called = true
return []byte(`{"kind":22242}`), nil
}
m := NewAuthManager(envoy, callback)
t.Cleanup(m.Close)
p.connect()
// EncloseAuthResponse wraps a JSON object as the second element — not a string
p.receive([]byte(envelope.EncloseAuthResponse([]byte(`{}`))))
Never(t, func() bool { return called }, "callback should not be invoked for malformed AUTH")
})
t.Run("close cleans up", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
called := false
callback := func(challenge string) ([]byte, error) {
called = true
return []byte(`{"kind":22242}`), nil
}
m := NewAuthManager(envoy, callback)
p.connect()
m.Close()
p.receive([]byte(envelope.EncloseAuthChallenge("abc")))
Never(t, func() bool { return called }, "callback should not fire after Close")
})
}
+5 -15
View File
@@ -17,7 +17,7 @@ import (
// ----------------------------------------------------------------------------
type EmbassyPlugin struct {
Connect func(url string) error
Connect func(url string, opts ...honeybee.ConnectOption) error
Remove func(url string) error
Send func(url string, data []byte) error
Events <-chan honeybee.PoolEvent
@@ -124,9 +124,8 @@ func NewEmbassy(
e.logger = slog.New(cfg.handler).With(slog.Any("component", comp))
}
e.wg.Add(2)
go e.routeEvents()
go e.routeInbox()
e.wg.Go(e.routeEvents)
e.wg.Go(e.routeInbox)
return e
}
@@ -237,8 +236,6 @@ func (e *Embassy) unsubscribeInboxLock(url string) {
}
func (e *Embassy) routeEvents() {
defer e.wg.Done()
for {
select {
case <-e.ctx.Done():
@@ -273,8 +270,6 @@ func (e *Embassy) routeEvents() {
}
func (e *Embassy) routeInbox() {
defer e.wg.Done()
for {
select {
case <-e.ctx.Done():
@@ -363,9 +358,8 @@ func newEnvoy(
e.logger = slog.New(e.handler).With(slog.Any("component", comp))
}
e.wg.Add(2)
go e.publishEvents()
go e.routeInbox()
e.wg.Go(e.publishEvents)
e.wg.Go(e.routeInbox)
return e
}
@@ -440,8 +434,6 @@ func (e *Envoy) SubscribeInbox(labels []string) <-chan InboxMessage {
}
func (e *Envoy) publishEvents() {
defer e.wg.Done()
for {
select {
case <-e.ctx.Done():
@@ -473,8 +465,6 @@ func (e *Envoy) publishEvents() {
}
func (e *Envoy) routeInbox() {
defer e.wg.Done()
for {
select {
case <-e.ctx.Done():
+3 -3
View File
@@ -3,9 +3,9 @@ module git.wisehodl.dev/jay/go-mana-prism
go 1.25.0
require (
git.wisehodl.dev/jay/go-honeybee v0.5.0
git.wisehodl.dev/jay/go-mana-component v0.1.0
git.wisehodl.dev/jay/go-roots-ws v0.1.0
git.wisehodl.dev/jay/go-honeybee v0.5.1
git.wisehodl.dev/jay/go-mana-component v0.2.0
git.wisehodl.dev/jay/go-roots-ws v0.2.0
github.com/stretchr/testify v1.11.1
)
+6
View File
@@ -1,9 +1,15 @@
git.wisehodl.dev/jay/go-honeybee v0.5.0 h1:tAhXMLJnxMMhWJstA1f3OYVsdQf0rstl5/AaJVzuCWk=
git.wisehodl.dev/jay/go-honeybee v0.5.0/go.mod h1:91H66sH5t1BHERGMihybeL1CDMu6vJgGuxjkUxfaqi4=
git.wisehodl.dev/jay/go-honeybee v0.5.1 h1:PuJeIOysCj8FV2GKfi2UL9xITOYJhkftFze8IdOuJZ0=
git.wisehodl.dev/jay/go-honeybee v0.5.1/go.mod h1:lOz4/P3ultn/PgFz1JiaetxMXvff48GiLK0b+s1rvUc=
git.wisehodl.dev/jay/go-mana-component v0.1.0 h1:wWYN5MzC9Hq3tEt4z7FjrwNuQz3rZY3RWAmgmNE8EZE=
git.wisehodl.dev/jay/go-mana-component v0.1.0/go.mod h1:r2ZaTjKzwV5JJfC5boikxtjAKusPrzlJU/7qul0EUqA=
git.wisehodl.dev/jay/go-mana-component v0.2.0 h1:uuk8YeFH/yQCtcpotkgQERDwFGY85/rNCUgk6hCGhW4=
git.wisehodl.dev/jay/go-mana-component v0.2.0/go.mod h1:r2ZaTjKzwV5JJfC5boikxtjAKusPrzlJU/7qul0EUqA=
git.wisehodl.dev/jay/go-roots-ws v0.1.0 h1:p1veCkpOmL26N//Qz7ekJOYj1Ck30ai4OKq9dxLjodk=
git.wisehodl.dev/jay/go-roots-ws v0.1.0/go.mod h1:ANQOOP13lHs2uQwYhrSQGAlL7+zR6QvbLzNPmNBJssQ=
git.wisehodl.dev/jay/go-roots-ws v0.2.0 h1:XzlrGu0//09SrTWWd6A8b/bGck2EZnJq2JOA+UJql28=
git.wisehodl.dev/jay/go-roots-ws v0.2.0/go.mod h1:ANQOOP13lHs2uQwYhrSQGAlL7+zR6QvbLzNPmNBJssQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+81 -27
View File
@@ -5,11 +5,14 @@ import (
"git.wisehodl.dev/jay/go-honeybee"
"git.wisehodl.dev/jay/go-mana-component"
"github.com/stretchr/testify/assert"
"sync"
"testing"
"time"
)
// ----------------------------------------------------------------------------
// Async Helpers
// ----------------------------------------------------------------------------
const (
TestTimeout = 2 * time.Second
@@ -27,6 +30,10 @@ func Never(t *testing.T, condition func() bool, msg string) {
assert.Never(t, condition, NegativeTestTimeout, TestTick, msg)
}
// ----------------------------------------------------------------------------
// Mock Pool
// ----------------------------------------------------------------------------
type mockPool struct {
plugin EmbassyPlugin
ctx context.Context
@@ -36,40 +43,46 @@ type mockPool struct {
events chan honeybee.PoolEvent
inbox chan honeybee.InboxMessage
sent chan []byte
sendMu sync.Mutex
sendErr error
}
// Mock Pool
func newMockPool(t *testing.T) *mockPool {
t.Helper()
ctx := component.MustNew(context.Background(), "prism", "test")
url := "wss://test"
p := &mockPool{}
added := make(chan struct{})
removed := make(chan struct{})
events := make(chan honeybee.PoolEvent, 16)
inbox := make(chan honeybee.InboxMessage, 16)
sent := make(chan []byte, 16)
p.ctx = component.MustNew(context.Background(), "prism", "test")
p.url = "wss://test"
plugin := EmbassyPlugin{
Connect: func(url string) error { close(added); return nil },
Remove: func(url string) error { close(removed); return nil },
Send: func(_ string, data []byte) error { sent <- data; return nil },
Events: events,
Inbox: inbox,
p.added = make(chan struct{})
p.removed = make(chan struct{})
p.events = make(chan honeybee.PoolEvent, 16)
p.inbox = make(chan honeybee.InboxMessage, 16)
p.sent = make(chan []byte, 16)
sendFunc := func(_ string, data []byte) error {
p.sendMu.Lock()
err := p.sendErr
p.sendMu.Unlock()
if err != nil {
return err
}
p.sent <- data
return nil
}
return &mockPool{
plugin: plugin,
ctx: ctx,
url: url,
added: added,
removed: removed,
events: events,
inbox: inbox,
sent: sent,
p.plugin = EmbassyPlugin{
Connect: func(url string, opts ...honeybee.ConnectOption) error {
close(p.added)
return nil
},
Remove: func(url string) error { close(p.removed); return nil },
Send: sendFunc,
Events: p.events,
Inbox: p.inbox,
}
return p
}
func (p *mockPool) connect() {
@@ -90,15 +103,56 @@ func (p *mockPool) receive(data []byte) {
}
}
// MockEnvoy
func (p *mockPool) setSendError(err error) {
p.sendMu.Lock()
defer p.sendMu.Unlock()
p.sendErr = err
}
func newMockEnvoy(t *testing.T) (*mockPool, *Envoy) {
// ----------------------------------------------------------------------------
// MockEnvoy
// ----------------------------------------------------------------------------
func newMockEnvoy(t *testing.T, opts ...EmbassyOption) (*mockPool, *Envoy) {
t.Helper()
p := newMockPool(t)
emb := NewEmbassy(p.ctx, p.plugin)
emb := NewEmbassy(p.ctx, p.plugin, opts...)
err := emb.Dispatch(p.url)
assert.NoError(t, err)
envoy := emb.Call(p.url)
return p, envoy
}
// ----------------------------------------------------------------------------
// MockObserver
// ----------------------------------------------------------------------------
type observerRecord struct {
peerID string
event any
}
type mockObserver struct {
mu sync.Mutex
records []observerRecord
}
func (o *mockObserver) Record(peerID string, event any) {
o.mu.Lock()
defer o.mu.Unlock()
o.records = append(o.records, observerRecord{peerID, event})
}
// EventsOf returns all recorded events of type T.
func EventsOf[T any](o *mockObserver) []T {
o.mu.Lock()
defer o.mu.Unlock()
var out []T
for _, r := range o.records {
if v, ok := r.event.(T); ok {
out = append(out, v)
}
}
return out
}
+75
View File
@@ -0,0 +1,75 @@
package prism
import (
"context"
"sync"
"time"
"git.wisehodl.dev/jay/go-roots-ws"
)
type NoticeReceived struct {
Message string
At time.Time
}
type Notice struct {
PeerID string
Message string
Timestamp time.Time
}
type NoticeHandler struct {
envoy *Envoy
inbox <-chan InboxMessage
notices chan Notice
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewNoticeHandler(e *Envoy) *NoticeHandler {
ctx, cancel := context.WithCancel(e.Context())
h := &NoticeHandler{
envoy: e,
inbox: e.SubscribeInbox([]string{"NOTICE"}),
notices: make(chan Notice, 16),
ctx: ctx,
cancel: cancel,
}
h.wg.Go(h.route)
return h
}
func (h *NoticeHandler) Notices() <-chan Notice { return h.notices }
func (h *NoticeHandler) Close() {
h.cancel()
h.wg.Wait()
close(h.notices)
}
func (h *NoticeHandler) route() {
for {
select {
case <-h.ctx.Done():
return
case msg := <-h.inbox:
message, err := envelope.FindNotice(msg.Data)
if err != nil {
continue
}
now := time.Now()
h.notices <- Notice{
PeerID: msg.ID,
Message: message,
Timestamp: now,
}
h.envoy.Observer().Record(msg.ID, NoticeReceived{
Message: message,
At: now,
})
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package prism
import (
"testing"
"git.wisehodl.dev/jay/go-roots-ws"
"github.com/stretchr/testify/assert"
)
func TestNoticeHandler(t *testing.T) {
t.Run("notice delivered", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
h := NewNoticeHandler(envoy)
t.Cleanup(h.Close)
p.receive([]byte(envelope.EncloseNotice("hello")))
var got Notice
Eventually(t, func() bool {
select {
case got = <-h.Notices():
return true
default:
return false
}
}, "notice not delivered")
assert.Equal(t, "hello", got.Message)
assert.Equal(t, p.url, got.PeerID)
assert.False(t, got.Timestamp.IsZero())
})
t.Run("malformed ignored", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
h := NewNoticeHandler(envoy)
t.Cleanup(h.Close)
p.receive([]byte("not json"))
Never(t, func() bool {
select {
case <-h.Notices():
return true
default:
return false
}
}, "notices channel should remain empty")
})
t.Run("observer notified", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
h := NewNoticeHandler(envoy)
t.Cleanup(h.Close)
p.receive([]byte(envelope.EncloseNotice("hello")))
Eventually(t, func() bool {
events := EventsOf[NoticeReceived](obs)
return len(events) == 1 &&
events[0].Message == "hello" &&
!events[0].At.IsZero()
}, "NoticeReceived observable not recorded")
})
t.Run("close cleans up", func(t *testing.T) {
_, envoy := newMockEnvoy(t)
h := NewNoticeHandler(envoy)
h.Close()
_, ok := <-h.Notices()
assert.False(t, ok, "notices channel should be closed")
})
}
+245
View File
@@ -0,0 +1,245 @@
package prism
import (
"context"
"errors"
"sync"
"time"
"git.wisehodl.dev/jay/go-roots-ws"
)
// ----------------------------------------------------------------------------
// Observables
// ----------------------------------------------------------------------------
type PublishDispatched struct {
EventID string
At time.Time
}
type PublishAccepted struct {
EventID string
At time.Time
}
type PublishRejected struct {
EventID string
Message string
At time.Time
}
type PublishTimeout struct {
EventID string
At time.Time
}
type PublishSendFailed struct {
EventID string
Err error
At time.Time
}
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
type publishResult struct {
accepted bool
message string
err error
}
type pendingEntry struct {
eventID string
eventJSON []byte
sent bool
result chan publishResult
mu sync.Mutex
once sync.Once
timer *time.Timer
}
// ----------------------------------------------------------------------------
// Event Publisher
// ----------------------------------------------------------------------------
type EventPublisher struct {
envoy *Envoy
pending map[string]*pendingEntry // event id -> pending entry
inbox <-chan InboxMessage
events <-chan OutboundPoolEvent
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
wg sync.WaitGroup
}
func NewEventPublisher(e *Envoy) *EventPublisher {
ctx, cancel := context.WithCancel(e.Context())
p := &EventPublisher{
envoy: e,
pending: make(map[string]*pendingEntry),
inbox: e.SubscribeInbox([]string{"OK"}),
events: e.SubscribeEvents(),
ctx: ctx,
cancel: cancel,
}
p.wg.Go(p.routeInbox)
p.wg.Go(p.handleEvents)
return p
}
func (p *EventPublisher) Publish(eventID string, eventJSON []byte, timeout time.Duration) (bool, string, error) {
entry := &pendingEntry{
eventID: eventID,
eventJSON: eventJSON,
result: make(chan publishResult, 1),
}
entry.timer = time.AfterFunc(timeout, func() {
p.mu.Lock()
p.deregister(eventID)
p.mu.Unlock()
p.envoy.Observer().Record(p.envoy.PeerID(),
PublishTimeout{EventID: eventID, At: time.Now()})
p.deliver(entry, publishResult{err: errors.New("publish timeout")})
})
p.mu.Lock()
p.pending[eventID] = entry
connected := p.envoy.IsConnected()
p.mu.Unlock()
if connected {
if err := p.trySend(entry); err != nil {
p.mu.Lock()
p.deregister(eventID)
p.mu.Unlock()
p.deliver(entry, publishResult{err: err})
}
}
r := <-entry.result
return r.accepted, r.message, r.err
}
func (p *EventPublisher) Close() {
p.cancel()
p.mu.Lock()
for id, e := range p.pending {
p.deregister(id)
p.deliver(e, publishResult{err: errors.New("publisher closed")})
}
p.mu.Unlock()
p.wg.Wait()
}
func (p *EventPublisher) trySend(entry *pendingEntry) error {
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.sent {
return nil
}
err := p.envoy.Send([]byte(envelope.EncloseEvent(entry.eventJSON)))
if err != nil {
p.envoy.Observer().Record(p.envoy.PeerID(),
PublishSendFailed{EventID: entry.eventID, Err: err, At: time.Now()})
return err
}
entry.sent = true
p.envoy.Observer().Record(p.envoy.PeerID(),
PublishDispatched{EventID: entry.eventID, At: time.Now()})
return nil
}
func (p *EventPublisher) deliver(entry *pendingEntry, result publishResult) {
entry.once.Do(func() {
entry.timer.Stop()
entry.result <- result
})
}
func (p *EventPublisher) routeInbox() {
for {
select {
case <-p.ctx.Done():
return
case msg := <-p.inbox:
eventID, accepted, message, err := envelope.FindOK(msg.Data)
if err != nil {
continue
}
p.mu.Lock()
entry, ok := p.pending[eventID]
if ok {
p.deregister(eventID)
}
p.mu.Unlock()
if !ok {
continue
}
if accepted {
p.envoy.Observer().Record(msg.ID,
PublishAccepted{EventID: eventID, At: time.Now()})
} else {
p.envoy.Observer().Record(msg.ID,
PublishRejected{EventID: eventID, Message: message, At: time.Now()})
}
p.deliver(entry, publishResult{accepted: accepted, message: message})
}
}
}
func (p *EventPublisher) handleEvents() {
for {
select {
case <-p.ctx.Done():
return
case ev := <-p.events:
switch ev.Kind {
case EventConnected:
p.mu.Lock()
var toSend []*pendingEntry
for _, e := range p.pending {
e.mu.Lock()
if !e.sent {
toSend = append(toSend, e)
}
e.mu.Unlock()
}
p.mu.Unlock()
for _, e := range toSend {
if err := p.trySend(e); err != nil {
p.mu.Lock()
p.deregister(e.eventID)
p.mu.Unlock()
p.deliver(e, publishResult{err: err})
}
}
case EventDisconnected:
p.mu.Lock()
for _, e := range p.pending {
e.mu.Lock()
e.sent = false
e.mu.Unlock()
}
p.mu.Unlock()
}
}
}
}
func (p *EventPublisher) deregister(eventID string) {
delete(p.pending, eventID)
}
+474
View File
@@ -0,0 +1,474 @@
package prism
import (
"errors"
"testing"
"git.wisehodl.dev/jay/go-roots-ws"
"github.com/stretchr/testify/assert"
)
func TestEventPublisher(t *testing.T) {
t.Run("publish accepted", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
const id = "aaaa1111"
eventJSON := []byte(`{"id":"aaaa1111","kind":1}`)
type result struct {
accepted bool
message string
err error
}
ch := make(chan result, 1)
go func() {
accepted, msg, err := pub.Publish(id, eventJSON, TestTimeout)
ch <- result{accepted, msg, err}
}()
var sent []byte
Eventually(t, func() bool {
select {
case sent = <-p.sent:
return true
default:
return false
}
}, "EVENT envelope not sent")
assert.Contains(t, string(sent), id)
p.receive([]byte(envelope.EncloseOK(id, true, "")))
Eventually(t, func() bool {
select {
case r := <-ch:
return r.accepted && r.message == "" && r.err == nil
default:
return false
}
}, "Publish did not return accepted result")
assert.Len(t, EventsOf[PublishDispatched](obs), 1)
assert.Len(t, EventsOf[PublishAccepted](obs), 1)
})
t.Run("publish rejected", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
const id = "bbbb2222"
eventJSON := []byte(`{"id":"bbbb2222","kind":1}`)
type result struct {
accepted bool
message string
err error
}
ch := make(chan result, 1)
go func() {
accepted, msg, err := pub.Publish(id, eventJSON, TestTimeout)
ch <- result{accepted, msg, err}
}()
Eventually(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "EVENT envelope not sent")
p.receive([]byte(envelope.EncloseOK(id, false, "rejected: not on whitelist")))
Eventually(t, func() bool {
select {
case r := <-ch:
return !r.accepted &&
r.message == "rejected: not on whitelist" &&
r.err == nil
default:
return false
}
}, "Publish did not return rejected result")
assert.Len(t, EventsOf[PublishDispatched](obs), 1)
assert.Len(t, EventsOf[PublishRejected](obs), 1)
})
t.Run("publish timeout", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
const id = "cccc3333"
eventJSON := []byte(`{"id":"cccc3333","kind":1}`)
ch := make(chan error, 1)
go func() {
_, _, err := pub.Publish(id, eventJSON, NegativeTestTimeout)
ch <- err
}()
var err error
Eventually(t, func() bool {
select {
case err = <-ch:
return err != nil
default:
return false
}
}, "Publish did not return a timeout error")
assert.ErrorContains(t, err, "publish timeout")
assert.Len(t, EventsOf[PublishDispatched](obs), 1)
assert.Len(t, EventsOf[PublishTimeout](obs), 1)
})
t.Run("concurrent correlation", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
p.connect()
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
type result struct {
accepted bool
message string
err error
}
chA := make(chan result, 1)
chB := make(chan result, 1)
go func() {
accepted, msg, err := pub.Publish(
"aaaa", []byte(`{"id":"aaaa"}`), TestTimeout)
chA <- result{accepted, msg, err}
}()
go func() {
accepted, msg, err := pub.Publish(
"bbbb", []byte(`{"id":"bbbb"}`), TestTimeout)
chB <- result{accepted, msg, err}
}()
// drain both EVENT envelopes before feeding OKs
Eventually(t, func() bool { return len(p.sent) == 2 },
"expected two EVENT envelopes")
<-p.sent
<-p.sent
// feed OK for B first, then A
p.receive([]byte(envelope.EncloseOK("bbbb", true, "b-msg")))
p.receive([]byte(envelope.EncloseOK("aaaa", false, "a-msg")))
Eventually(t, func() bool {
select {
case r := <-chA:
return !r.accepted &&
r.message == "a-msg" &&
r.err == nil
default:
return false
}
}, "caller A did not receive its result")
Eventually(t, func() bool {
select {
case r := <-chB:
return r.accepted && r.message == "b-msg" && r.err == nil
default:
return false
}
}, "caller B did not receive its result")
})
t.Run("publish while disconnected times out", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
// do not connect
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
ch := make(chan error, 1)
go func() {
_, _, err := pub.Publish(
"dddd4444", []byte(`{"id":"dddd4444"}`), NegativeTestTimeout)
ch <- err
}()
Eventually(t, func() bool {
select {
case err := <-ch:
return err != nil
default:
return false
}
}, "Publish did not return a timeout error")
Never(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "EVENT envelope should not be sent while disconnected")
})
t.Run("held event sent on connect", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
// do not connect
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
const id = "eeee5555"
eventJSON := []byte(`{"id":"eeee5555"}`)
type result struct {
accepted bool
message string
err error
}
ch := make(chan result, 1)
go func() {
accepted, msg, err := pub.Publish(id, eventJSON, TestTimeout)
ch <- result{accepted, msg, err}
}()
Never(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "EVENT envelope should not be sent before connect")
p.connect()
Eventually(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "EVENT envelope not sent after connect")
p.receive([]byte(envelope.EncloseOK(id, true, "")))
Eventually(t, func() bool {
select {
case r := <-ch:
return r.accepted && r.message == "" && r.err == nil
default:
return false
}
}, "Publish did not return accepted result after connect")
})
t.Run("resend after disconnect if OK not received in time", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
p.connect()
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
const id = "ffff6666"
eventJSON := []byte(`{"id":"ffff6666"}`)
type result struct {
accepted bool
err error
}
ch := make(chan result, 1)
go func() {
accepted, _, err := pub.Publish(id, eventJSON, TestTimeout)
ch <- result{accepted, err}
}()
// first EVENT sent
Eventually(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "first EVENT envelope not sent")
p.disconnect()
p.connect()
// second EVENT sent after reconnect
// because OK was not yet received
Eventually(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "second EVENT envelope not sent after reconnect")
p.receive([]byte(envelope.EncloseOK(id, true, "")))
Eventually(t, func() bool {
select {
case r := <-ch:
return r.accepted && r.err == nil
default:
return false
}
}, "Publish did not return accepted result after resend")
})
t.Run("send failure delivers error", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
p.setSendError(errors.New("send failed"))
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
ch := make(chan error, 1)
go func() {
_, _, err := pub.Publish(
"gggg7777", []byte(`{"id":"gggg7777"}`), TestTimeout)
ch <- err
}()
var err error
Eventually(t, func() bool {
select {
case err = <-ch:
return err != nil
default:
return false
}
}, "Publish did not return a send error")
assert.ErrorContains(t, err, "send failed")
// on connect event handler may race with Publish()
assert.GreaterOrEqual(t, len(EventsOf[PublishSendFailed](obs)), 1)
})
t.Run("disconnect then send failure on reconnect", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
ch := make(chan error, 1)
go func() {
_, _, err := pub.Publish(
"hhhh8888", []byte(`{"id":"hhhh8888"}`), TestTimeout)
ch <- err
}()
// first EVENT sent successfully
Eventually(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "first EVENT envelope not sent")
p.disconnect()
p.setSendError(errors.New("send failed on reconnect"))
p.connect()
var err error
Eventually(t, func() bool {
select {
case err = <-ch:
return err != nil
default:
return false
}
}, "Publish did not return a send error after reconnect")
assert.ErrorContains(t, err, "send failed on reconnect")
assert.Len(t, EventsOf[PublishSendFailed](obs), 1)
})
t.Run("close cancels pending", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
p.connect()
pub := NewEventPublisher(envoy)
ch := make(chan error, 1)
go func() {
_, _, err := pub.Publish(
"iiii9999", []byte(`{"id":"iiii9999"}`), TestTimeout)
ch <- err
}()
// wait for EVENT to be sent before closing
Eventually(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "EVENT envelope not sent")
pub.Close()
var err error
Eventually(t, func() bool {
select {
case err = <-ch:
return err != nil
default:
return false
}
}, "Publish did not return a cancellation error")
assert.ErrorContains(t, err, "closed")
})
t.Run("unknown OK ignored", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
pub := NewEventPublisher(envoy)
t.Cleanup(pub.Close)
p.receive([]byte(envelope.EncloseOK("no-such-id", true, "")))
Never(t, func() bool {
pub.mu.Lock()
pendingLen := len(pub.pending)
pub.mu.Unlock()
return pendingLen > 0 ||
len(EventsOf[PublishAccepted](obs)) > 0 ||
len(EventsOf[PublishRejected](obs)) > 0 ||
len(EventsOf[PublishTimeout](obs)) > 0 ||
len(EventsOf[PublishSendFailed](obs)) > 0
}, "unknown OK should produce no side effects")
})
}
+4 -9
View File
@@ -157,17 +157,16 @@ func NewRequestManager(e *Envoy) *RequestManager {
ctx: ctx,
cancel: cancel,
observer: e.Observer(),
handler: e.Handler(),
}
if m.handler != nil {
if e.Handler() != nil {
comp := component.FromContext(ctx)
m.handler = e.Handler()
m.logger = slog.New(m.handler).With(slog.Any("component", comp))
}
m.wg.Add(2)
go m.handleEvents()
go m.routeInbox()
m.wg.Go(m.handleEvents)
m.wg.Go(m.routeInbox)
return m
}
@@ -354,8 +353,6 @@ func (m *RequestManager) deregister(req *request) {
}
func (m *RequestManager) handleEvents() {
defer m.wg.Done()
for {
select {
case <-m.ctx.Done():
@@ -381,8 +378,6 @@ func (m *RequestManager) handleEvents() {
}
func (m *RequestManager) routeInbox() {
defer m.wg.Done()
for {
select {
case <-m.ctx.Done():
+89 -6
View File
@@ -1,6 +1,7 @@
package prism
import (
"errors"
"git.wisehodl.dev/jay/go-roots-ws"
"github.com/stretchr/testify/assert"
"testing"
@@ -78,7 +79,8 @@ func TestRequestManager_Options(t *testing.T) {
func TestRequestManager_Stream(t *testing.T) {
t.Run("sends req when connected", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
Eventually(t, envoy.IsConnected, "envoy should be connected")
@@ -104,6 +106,12 @@ func TestRequestManager_Stream(t *testing.T) {
}, "expected REQ send")
assert.Equal(t, []byte(envelope.EncloseReq(id, filters)), got)
Eventually(t, func() bool {
return len(EventsOf[ReqDispatched](obs)) == 1
}, "expected ReqDispatched observable")
dispatched := EventsOf[ReqDispatched](obs)
assert.Equal(t, id, dispatched[0].SubID)
})
t.Run("does not send req when disconnected", func(t *testing.T) {
@@ -130,7 +138,8 @@ func TestRequestManager_Stream(t *testing.T) {
})
t.Run("forwards events to caller", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
Eventually(t, envoy.IsConnected, "envoy should be connected")
@@ -172,10 +181,18 @@ func TestRequestManager_Stream(t *testing.T) {
assert.Len(t, got, 2)
assert.Equal(t, eventA, got[0].Data)
assert.Equal(t, eventB, got[1].Data)
Eventually(t, func() bool {
return len(EventsOf[FirstEventReceived](obs)) == 1
}, "expected FirstEventReceived observable")
first := EventsOf[FirstEventReceived](obs)
assert.Equal(t, id, first[0].SubID)
assert.False(t, first[0].ReceivedAt.IsZero())
})
t.Run("ignores eose", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
Eventually(t, envoy.IsConnected, "envoy should be connected")
@@ -225,10 +242,17 @@ func TestRequestManager_Stream(t *testing.T) {
return false
}
}, "expected event after eose")
Eventually(t, func() bool {
return len(EventsOf[StreamEOSEReceived](obs)) == 1
}, "expected StreamEOSEReceived observable")
eoseEvents := EventsOf[StreamEOSEReceived](obs)
assert.Equal(t, id, eoseEvents[0].SubID)
})
t.Run("closed deregisters and signals caller", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
Eventually(t, envoy.IsConnected, "envoy should be connected")
@@ -261,6 +285,13 @@ func TestRequestManager_Stream(t *testing.T) {
}, "expected closed signal")
assert.Equal(t, "error: test", got.Data)
Eventually(t, func() bool {
return len(EventsOf[ClosedReceived](obs)) == 1
}, "expected ClosedReceived observable")
closedEvents := EventsOf[ClosedReceived](obs)
assert.Equal(t, id, closedEvents[0].SubID)
assert.Equal(t, "error: test", closedEvents[0].Message)
Eventually(t, func() bool {
select {
case _, ok := <-events:
@@ -284,6 +315,45 @@ func TestRequestManager_Stream(t *testing.T) {
m.mu.RUnlock()
assert.False(t, ok, "registration should be removed from reqs")
})
t.Run("send failure recorded", func(t *testing.T) {
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
Eventually(t, envoy.IsConnected, "envoy should be connected")
m := NewRequestManager(envoy)
t.Cleanup(func() { m.Close() })
// disconnect, arm send error, reconnect so the send attempt
// happens after the error is in place
p.disconnect()
p.setSendError(errors.New("send failed"))
p.connect()
Eventually(t, envoy.IsConnected, "envoy should be reconnected")
filters := [][]byte{[]byte(`{}`)}
id, _, _, err := m.Stream(filters)
assert.NoError(t, err)
Eventually(t, func() bool {
// on connect event handler may race with Stream()
return len(EventsOf[ReqSendFailed](obs)) >= 1
}, "expected ReqSendFailed observable")
failed := EventsOf[ReqSendFailed](obs)
assert.Equal(t, id, failed[0].SubID)
assert.NotNil(t, failed[0].Err)
Never(t, func() bool {
select {
case <-p.sent:
return true
default:
return false
}
}, "no REQ envelope should have been sent")
})
}
func TestRequestManager_Cancel(t *testing.T) {
@@ -377,7 +447,8 @@ func TestRequestManager_Cancel(t *testing.T) {
func TestRequestManager_Query(t *testing.T) {
t.Run("returns events and nil closed on eose", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
Eventually(t, envoy.IsConnected, "envoy should be connected")
@@ -387,6 +458,7 @@ func TestRequestManager_Query(t *testing.T) {
filters := [][]byte{[]byte(`{}`)}
eventData := []byte(`{"id":"abc"}`)
var querySubID string
go func() {
// wait for the REQ to arrive, extract the sub ID
reqBytes := <-p.sent
@@ -395,6 +467,7 @@ func TestRequestManager_Query(t *testing.T) {
t.Errorf("FindReq: %v", err)
return
}
querySubID = subID
// inject three EVENTs then EOSE
for range 3 {
raw := envelope.EncloseSubscriptionEvent(subID, eventData)
@@ -409,6 +482,12 @@ func TestRequestManager_Query(t *testing.T) {
assert.Len(t, events, 3)
assert.Nil(t, closed)
Eventually(t, func() bool {
return len(EventsOf[QueryEOSEReceived](obs)) == 1
}, "expected QueryEOSEReceived observable")
qeose := EventsOf[QueryEOSEReceived](obs)
assert.Equal(t, querySubID, qeose[0].SubID)
// CLOSE envelope should have been sent after EOSE
var closeEnv []byte
select {
@@ -451,7 +530,8 @@ func TestRequestManager_Query(t *testing.T) {
})
t.Run("returns partial events on timeout", func(t *testing.T) {
p, envoy := newMockEnvoy(t)
obs := &mockObserver{}
p, envoy := newMockEnvoy(t, WithEmbassyObserver(obs))
p.connect()
Eventually(t, envoy.IsConnected, "envoy should be connected")
@@ -483,6 +563,9 @@ func TestRequestManager_Query(t *testing.T) {
assert.GreaterOrEqual(t, elapsed, queryTimeout)
assert.Len(t, events, 2)
assert.Nil(t, closed)
missed := EventsOf[MissedEOSE](obs)
assert.Len(t, missed, 1)
})
t.Run("connects within timeout and returns events", func(t *testing.T) {