diff --git a/internal/bus/bus.go b/internal/bus/bus.go new file mode 100644 index 0000000..db04f20 --- /dev/null +++ b/internal/bus/bus.go @@ -0,0 +1,124 @@ +// Package bus provides an in-process publish/subscribe event bus. It is the +// asynchronous communication channel mandated by the Cyrene architecture: +// synchronous calls go through interfaces and dependency injection, while +// asynchronous events and agent-to-agent messages flow through this bus. The +// API is deliberately NATS-like so the bus can later be swapped for an +// out-of-process NATS deployment (or another transport) without changing call +// sites; see DevDocs/00-overview.md and DevDocs/02-development-constraints.md. +// +// # Matching +// +// Both subjects and subscription patterns are dot-separated token strings. +// Two wildcards are supported: +// +// - "*" matches exactly one non-empty token. For example "runtime.*.start" +// matches "runtime.loop.start" but not "runtime.loop". +// - ">" matches one or more trailing tokens and may only appear as the last +// token (NATS semantics). For example "runtime.>" matches +// "runtime.loop.start" but not "runtime". +// +// A pattern with no wildcard matches only an identical subject. +// +// # Delivery semantics +// +// Publish fans out asynchronously and never waits for a handler to finish. +// Each subscription is served by a single worker goroutine reading from a +// bounded per-subscription channel, which serializes handler execution for +// that subscription and bounds its memory. When the buffer is full the event +// is dropped and counted (see busImpl.Dropped) instead of blocking the +// publisher; that trade-off is what keeps Publish non-blocking and is the +// documented cost of an unbounded producer. +// +// Handler errors are ignored by this M0 core; consumers that need them should +// capture and log them at the call site. A panic raised by a handler is +// recovered so a misbehaving subscriber cannot take down the bus or starve +// other subscribers. +package bus + +import ( + "context" + "errors" +) + +// Event is a single published message. +type Event struct { + // Subject is the token string the message was published to. + Subject string + // Payload is the arbitrary value associated with the event. + Payload any +} + +// Handler processes a delivered Event. It runs on the subscription's own +// worker goroutine, so invocations for the same subscription are serialized. +// A non-nil error is ignored by the M0 core (see the package comment). +type Handler func(ctx context.Context, e Event) error + +// Bus is the in-process publish/subscribe contract. Keep this interface +// narrow so an alternate transport (for example NATS) can satisfy it without +// changing consumers, and so it is trivial to mock in tests. +type Bus interface { + // Publish asynchronously fans an event out to every subscription whose + // pattern matches subject. It never blocks waiting for a handler to run; + // when a subscription's buffer is full the event is dropped for that + // subscription and counted. It returns ErrClosed if the bus is closed and + // a wrapped error if subject is invalid or ctx is canceled. + Publish(ctx context.Context, subject string, payload any) error + // Subscribe registers h for events whose subject matches pattern and + // returns an unsubscribe function that removes the registration. When ctx + // is canceled the registration is removed automatically. It returns + // ErrClosed if the bus is already closed. + Subscribe(ctx context.Context, pattern string, h Handler) (unsubscribe func(), err error) + // Close is idempotent and safe to call concurrently. It prevents further + // Publish/Subscribe calls, unsubscribes every registration, cancels the + // handlers' contexts, and stops every worker. It returns nil. + Close() error +} + +// Sentinel errors returned by the bus. Use errors.Is to test for them. +var ( + // ErrClosed is returned when publishing to or subscribing on a closed bus. + ErrClosed = errors.New("bus: closed") + // ErrInvalidArgument is returned when a subject is empty, a pattern is + // malformed, or a handler is nil. + ErrInvalidArgument = errors.New("bus: invalid argument") +) + +// defaultQueueSize is the per-subscription event buffer used when no option +// overrides it. +const defaultQueueSize = 64 + +// config carries the resolved construction options. +type config struct { + queueSize int +} + +// Option customizes a bus built by New. Options are applied in order before +// the bus starts accepting traffic. +type Option func(*config) + +// WithQueueSize sets the per-subscription event buffer size. Buffers bound how +// many undelivered events a subscription may hold before events begin to be +// dropped. Non-positive values are ignored and the default is kept. +func WithQueueSize(n int) Option { + return func(c *config) { + if n > 0 { + c.queueSize = n + } + } +} + +// New builds a bus with the supplied options. The returned *busImpl +// implements Bus and also exposes busImpl.Dropped for observability. +func New(opts ...Option) *busImpl { + cfg := config{queueSize: defaultQueueSize} + for _, opt := range opts { + opt(&cfg) + } + ctx, cancel := context.WithCancel(context.Background()) + return &busImpl{ + queueSize: cfg.queueSize, + subs: make(map[uint64]*subscription), + ctx: ctx, + cancel: cancel, + } +} diff --git a/internal/bus/bus_test.go b/internal/bus/bus_test.go new file mode 100644 index 0000000..c0cca52 --- /dev/null +++ b/internal/bus/bus_test.go @@ -0,0 +1,454 @@ +package bus + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +// recvWithTimeout blocks until a value is delivered or the deadline passes, +// failing the test on timeout. It makes delivery assertions deterministic +// instead of relying on a bare sleep. +func recvWithTimeout[T any](t *testing.T, ch <-chan T, timeout time.Duration) T { + t.Helper() + select { + case v := <-ch: + return v + case <-time.After(timeout): + t.Fatalf("timed out waiting for a delivery after %v", timeout) + var zero T + return zero + } +} + +// assertNoRecv fails if a value arrives on ch within d. It is used for the +// negative cases where the registration is already gone, so nothing is ever +// enqueued and the channel stays empty. +func assertNoRecv[T any](t *testing.T, ch <-chan T, d time.Duration) { + t.Helper() + select { + case v := <-ch: + t.Fatalf("unexpected delivery: %v", v) + case <-time.After(d): + } +} + +// waitSubs polls the registry until it holds exactly want subscriptions, with a +// deadline, so context-cancellation behavior can be asserted deterministically. +func waitSubs(t *testing.T, b *busImpl, want int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + b.mu.RLock() + got := len(b.subs) + b.mu.RUnlock() + if got == want { + return + } + if time.Now().After(deadline) { + t.Fatalf("subscriber count = %d, want %d", got, want) + } + time.Sleep(time.Millisecond) + } +} + +func TestMatch_Rules(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern string + subject string + want bool + }{ + {name: "exact match", pattern: "runtime.loop.start", subject: "runtime.loop.start", want: true}, + {name: "exact mismatch", pattern: "runtime.loop.start", subject: "runtime.loop.stop", want: false}, + {name: "exact subject has more tokens", pattern: "runtime.loop", subject: "runtime.loop.start", want: false}, + {name: "exact subject has fewer tokens", pattern: "runtime.loop.start", subject: "runtime", want: false}, + {name: "gt matches nested", pattern: "runtime.>", subject: "runtime.loop.start", want: true}, + {name: "gt matches one child", pattern: "runtime.>", subject: "runtime.loop", want: true}, + {name: "gt does not match parent", pattern: "runtime.>", subject: "runtime", want: false}, + {name: "gt alone matches nested", pattern: ">", subject: "runtime.loop.start", want: true}, + {name: "gt alone matches single", pattern: ">", subject: "runtime", want: true}, + {name: "star matches one token", pattern: "runtime.*.start", subject: "runtime.loop.start", want: true}, + {name: "star rejects extra token", pattern: "runtime.*.start", subject: "runtime.loop.x.start", want: false}, + {name: "star rejects value mismatch", pattern: "runtime.*.start", subject: "runtime.loop.stop", want: false}, + {name: "star alone matches single", pattern: "*", subject: "runtime", want: true}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := match(tt.pattern, tt.subject); got != tt.want { + t.Errorf("match(%q, %q) = %v, want %v", tt.pattern, tt.subject, got, tt.want) + } + }) + } +} + +func TestPublish_ExactMatch(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + got := make(chan any, 4) + + if _, err := b.Subscribe(context.Background(), "runtime.loop.start", func(ctx context.Context, e Event) error { + got <- e.Payload + return nil + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if err := b.Publish(context.Background(), "runtime.loop.start", "hit"); err != nil { + t.Fatalf("Publish: %v", err) + } + if p := recvWithTimeout(t, got, 2*time.Second); p != "hit" { + t.Fatalf("payload = %v, want hit", p) + } + + if err := b.Publish(context.Background(), "runtime.loop.stop", "miss"); err != nil { + t.Fatalf("Publish: %v", err) + } + assertNoRecv(t, got, 100*time.Millisecond) +} + +func TestPublish_GTailWildcard(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + got := make(chan any, 4) + + if _, err := b.Subscribe(context.Background(), "runtime.>", func(ctx context.Context, e Event) error { + got <- e.Payload + return nil + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + for _, subject := range []string{"runtime.loop.start", "runtime.loop", "runtime.loop.a.b"} { + if err := b.Publish(context.Background(), subject, subject); err != nil { + t.Fatalf("Publish(%q): %v", subject, err) + } + if p := recvWithTimeout(t, got, 2*time.Second); p != subject { + t.Fatalf("payload = %v, want %q", p, subject) + } + } + + // "runtime.>" must not match the exact "runtime", and a different root + // must not match at all. + if err := b.Publish(context.Background(), "runtime", "miss-root"); err != nil { + t.Fatalf("Publish: %v", err) + } + if err := b.Publish(context.Background(), "other.x", "miss-other"); err != nil { + t.Fatalf("Publish: %v", err) + } + assertNoRecv(t, got, 100*time.Millisecond) +} + +func TestPublish_StarWildcard(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + got := make(chan any, 4) + + if _, err := b.Subscribe(context.Background(), "runtime.*.start", func(ctx context.Context, e Event) error { + got <- e.Payload + return nil + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if err := b.Publish(context.Background(), "runtime.loop.start", "hit"); err != nil { + t.Fatalf("Publish: %v", err) + } + if p := recvWithTimeout(t, got, 2*time.Second); p != "hit" { + t.Fatalf("payload = %v, want hit", p) + } + + for _, subject := range []string{"runtime.loop.x.start", "runtime.loop.stop", "runtime.loop"} { + if err := b.Publish(context.Background(), subject, "miss"); err != nil { + t.Fatalf("Publish(%q): %v", subject, err) + } + } + assertNoRecv(t, got, 100*time.Millisecond) +} + +func TestSubscribe_UnsubscribeStopsDelivery(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + got := make(chan any, 2) + + unsub, err := b.Subscribe(context.Background(), "a.b", func(ctx context.Context, e Event) error { + got <- e.Payload + return nil + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if err := b.Publish(context.Background(), "a.b", "first"); err != nil { + t.Fatalf("Publish: %v", err) + } + if p := recvWithTimeout(t, got, 2*time.Second); p != "first" { + t.Fatalf("payload = %v, want first", p) + } + + unsub() + if err := b.Publish(context.Background(), "a.b", "second"); err != nil { + t.Fatalf("Publish: %v", err) + } + assertNoRecv(t, got, 100*time.Millisecond) +} + +func TestPublish_NonBlockingSlowHandler(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + handler := func(ctx context.Context, e Event) error { + once.Do(func() { close(started) }) + <-release + return nil + } + if _, err := b.Subscribe(context.Background(), "slow", handler); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if err := b.Publish(context.Background(), "slow", "payload"); err != nil { + t.Fatalf("Publish: %v", err) + } + + // The handler started and is still blocked on release. Because Publish has + // already returned, it did not wait for the handler to finish. + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("handler did not start") + } + select { + case <-release: + t.Fatal("handler finished before release was closed") + case <-time.After(50 * time.Millisecond): + } + close(release) +} + +func TestBus_CloseStopsDelivery(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + got := make(chan any, 1) + + if _, err := b.Subscribe(context.Background(), "a.b", func(ctx context.Context, e Event) error { + got <- e.Payload + return nil + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if err := b.Publish(context.Background(), "a.b", "before-close"); err != nil { + t.Fatalf("Publish: %v", err) + } + if p := recvWithTimeout(t, got, 2*time.Second); p != "before-close" { + t.Fatalf("payload = %v, want before-close", p) + } + + if err := b.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := b.Publish(context.Background(), "a.b", "after-close"); !errors.Is(err, ErrClosed) { + t.Fatalf("Publish after Close error = %v, want ErrClosed", err) + } + assertNoRecv(t, got, 100*time.Millisecond) +} + +func TestPublish_InvalidSubject(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + + for _, subject := range []string{"", ".a", "a.", "a..b"} { + err := b.Publish(context.Background(), subject, "x") + if !errors.Is(err, ErrInvalidArgument) { + t.Errorf("Publish(%q) error = %v, want ErrInvalidArgument", subject, err) + } + } +} + +func TestSubscribe_InvalidPattern(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + + for _, pattern := range []string{"", ".a", "a.", "a..b", "a.>.b"} { + _, err := b.Subscribe(context.Background(), pattern, func(ctx context.Context, e Event) error { return nil }) + if !errors.Is(err, ErrInvalidArgument) { + t.Errorf("Subscribe(%q) error = %v, want ErrInvalidArgument", pattern, err) + } + } +} + +func TestSubscribe_NilHandler(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + + _, err := b.Subscribe(context.Background(), "a.b", nil) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("Subscribe(nil) error = %v, want ErrInvalidArgument", err) + } +} + +func TestBus_ClosedError(t *testing.T) { + t.Parallel() + + b := New() + if err := b.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if err := b.Publish(context.Background(), "a.b", "x"); !errors.Is(err, ErrClosed) { + t.Fatalf("Publish after Close error = %v, want ErrClosed", err) + } + if _, err := b.Subscribe(context.Background(), "a.b", func(ctx context.Context, e Event) error { return nil }); !errors.Is(err, ErrClosed) { + t.Fatalf("Subscribe after Close error = %v, want ErrClosed", err) + } + if err := b.Close(); err != nil { + t.Fatalf("second Close error = %v, want nil", err) + } +} + +func TestBus_DroppedCounter(t *testing.T) { + t.Parallel() + + b := New(WithQueueSize(1)) + defer b.Close() + + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + if _, err := b.Subscribe(context.Background(), "d", func(ctx context.Context, e Event) error { + once.Do(func() { close(started) }) + <-release + return nil + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + if err := b.Publish(context.Background(), "d", "first"); err != nil { + t.Fatalf("Publish first: %v", err) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("handler did not start; worker failed to drain") + } + + // The worker is now blocked in the handler, so it consumed "first" and the + // single-slot buffer is empty. The next enqueue fills it; the two after + // that find it full and are dropped. + for _, payload := range []string{"fill1", "fill2", "fill3"} { + if err := b.Publish(context.Background(), "d", payload); err != nil { + t.Fatalf("Publish %s: %v", payload, err) + } + } + if got := b.Dropped(); got != 2 { + t.Fatalf("Dropped() = %d, want 2", got) + } + close(release) +} + +func TestSubscribe_ContextCancelAutoUnsub(t *testing.T) { + t.Parallel() + + b := New() + defer b.Close() + got := make(chan any, 1) + + ctx, cancel := context.WithCancel(context.Background()) + if _, err := b.Subscribe(ctx, "a.b", func(ctx context.Context, e Event) error { + got <- e.Payload + return nil + }); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + cancel() + waitSubs(t, b, 0) + + if err := b.Publish(context.Background(), "a.b", "after-cancel"); err != nil { + t.Fatalf("Publish: %v", err) + } + assertNoRecv(t, got, 100*time.Millisecond) +} + +func TestPublish_ConcurrentSafety(t *testing.T) { + t.Parallel() + + b := New(WithQueueSize(8)) + defer b.Close() + + const workers = 8 + const iterations = 200 + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + if i%3 == 0 { + unsub, err := b.Subscribe(context.Background(), "c", func(ctx context.Context, e Event) error { + return nil + }) + if err == nil { + unsub() + } + continue + } + _ = b.Publish(context.Background(), "c", i) + _ = b.Publish(context.Background(), "c.x", i) + } + }() + } + wg.Wait() + + if err := b.Close(); err != nil { + t.Fatalf("Close: %v", err) + } +} + +func TestClose_Concurrent(t *testing.T) { + t.Parallel() + + b := New() + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = b.Publish(context.Background(), "a.b", 1) + _, _ = b.Subscribe(context.Background(), "a.b", func(ctx context.Context, e Event) error { return nil }) + _ = b.Close() + }() + } + wg.Wait() + + if err := b.Close(); err != nil { + t.Fatalf("Close: %v", err) + } +} diff --git a/internal/bus/impl.go b/internal/bus/impl.go new file mode 100644 index 0000000..339c36b --- /dev/null +++ b/internal/bus/impl.go @@ -0,0 +1,192 @@ +package bus + +import ( + "context" + "fmt" + "sync" + "sync/atomic" +) + +// Compile-time assertion that *busImpl satisfies Bus. +var _ Bus = (*busImpl)(nil) + +// busImpl is the default in-process Bus implementation. It is safe for +// concurrent use. +type busImpl struct { + queueSize int + mu sync.RWMutex + subs map[uint64]*subscription + nextID atomic.Uint64 + dropped atomic.Uint64 + closed atomic.Bool + ctx context.Context + cancel context.CancelFunc + closeOnce sync.Once +} + +// Dropped returns the total number of events dropped because a subscription's +// buffer was full. It is useful for observing backpressure on the bus. +func (b *busImpl) Dropped() uint64 { + return b.dropped.Load() +} + +// subscription is one registered handler plus its bounded queue and the single +// worker goroutine that drains it. +type subscription struct { + id uint64 + pattern string + handler Handler + events chan Event + done chan struct{} + ctx context.Context + cancel context.CancelFunc + once sync.Once +} + +// stop idempotently cancels the handler context and signals the worker to +// exit. It is safe to call more than once and from more than one goroutine. +func (s *subscription) stop() { + s.once.Do(func() { + s.cancel() + close(s.done) + }) +} + +// Publish fans an event out to every matching subscription without waiting for +// any handler. It holds the read lock across the fan-out so a concurrent +// Close cannot stop a worker mid-broadcast. Events that cannot be queued +// because a subscription buffer is full are dropped and counted. +func (b *busImpl) Publish(ctx context.Context, subject string, payload any) error { + if err := validateSubject(subject); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("bus: publish %q: %w", subject, err) + } + + b.mu.RLock() + defer b.mu.RUnlock() + if b.closed.Load() { + return fmt.Errorf("bus is closed: %w", ErrClosed) + } + + ev := Event{Subject: subject, Payload: payload} + for _, sub := range b.subs { + if !match(sub.pattern, subject) { + continue + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("bus: publish %q: %w", subject, err) + } + select { + case sub.events <- ev: + default: + b.dropped.Add(1) + } + } + return nil +} + +// Subscribe registers a handler and returns an unsubscribe function. The +// registration is removed automatically when the caller's context is canceled. +func (b *busImpl) Subscribe(ctx context.Context, pattern string, h Handler) (func(), error) { + if err := validatePattern(pattern); err != nil { + return nil, err + } + if h == nil { + return nil, fmt.Errorf("bus: handler must not be nil: %w", ErrInvalidArgument) + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("bus: subscribe %q: %w", pattern, err) + } + + subCtx, subCancel := context.WithCancel(b.ctx) + sub := &subscription{ + id: b.nextID.Add(1), + pattern: pattern, + handler: h, + events: make(chan Event, b.queueSize), + done: make(chan struct{}), + ctx: subCtx, + cancel: subCancel, + } + + b.mu.Lock() + if b.closed.Load() { + b.mu.Unlock() + subCancel() + return nil, fmt.Errorf("bus is closed: %w", ErrClosed) + } + b.subs[sub.id] = sub + b.mu.Unlock() + + b.startWorker(sub) + + go func() { + select { + case <-ctx.Done(): + b.removeSub(sub.id, sub) + case <-sub.done: + // Already removed; nothing to do. + } + }() + + return func() { b.removeSub(sub.id, sub) }, nil +} + +// Close is idempotent and safe to call concurrently. It marks the bus closed, +// cancels every handler context, unsubscribes all registrations, and signals +// every worker to stop. It does not wait for in-flight handlers, which instead +// observe their canceled context. +func (b *busImpl) Close() error { + b.closeOnce.Do(func() { + b.mu.Lock() + b.closed.Store(true) + b.cancel() + for _, sub := range b.subs { + sub.stop() + } + b.subs = make(map[uint64]*subscription) + b.mu.Unlock() + }) + return nil +} + +// startWorker launches the single goroutine that drains the subscription queue +// and runs the handler for each event. +func (b *busImpl) startWorker(sub *subscription) { + go func() { + for { + select { + case <-sub.done: + return + case ev := <-sub.events: + b.deliver(sub, ev) + } + } + }() +} + +// deliver runs one handler invocation. It recovers panics so a misbehaving +// subscriber cannot crash the bus, and it ignores the handler's returned error +// (see the package comment). +func (b *busImpl) deliver(sub *subscription, ev Event) { + defer func() { + _ = recover() + }() + _ = sub.handler(sub.ctx, ev) +} + +// removeSub unsubscribes a registration exactly once. The events channel is +// never closed by a sender, so a concurrent Publish cannot panic on a send; +// this method only closes the done channel after the registration is gone. +func (b *busImpl) removeSub(id uint64, sub *subscription) { + b.mu.Lock() + if _, ok := b.subs[id]; ok { + delete(b.subs, id) + b.mu.Unlock() + sub.stop() + return + } + b.mu.Unlock() +} diff --git a/internal/bus/match.go b/internal/bus/match.go new file mode 100644 index 0000000..e51a4f7 --- /dev/null +++ b/internal/bus/match.go @@ -0,0 +1,63 @@ +package bus + +import ( + "fmt" + "strings" +) + +// match reports whether subject matches pattern using the wildcard rules +// described in the package comment: "*" matches exactly one non-empty token, +// ">" matches one or more trailing tokens and must be the last token, and any +// other token matches an identical subject token. Callers must have validated +// both inputs, so an empty token or a misplaced ">" never reaches here. +func match(pattern, subject string) bool { + p := strings.Split(pattern, ".") + s := strings.Split(subject, ".") + i, j := 0, 0 + for i < len(p) { + if p[i] == ">" { + // ">" is guaranteed to be the last token; it needs at least one + // remaining subject token. + return j < len(s) + } + if j >= len(s) { + return false + } + if p[i] != "*" && p[i] != s[j] { + return false + } + i++ + j++ + } + return j == len(s) +} + +// validateSubject rejects an empty subject or one containing an empty token +// (leading/trailing/repeated dots). +func validateSubject(subject string) error { + if subject == "" { + return fmt.Errorf("bus: subject must not be empty: %w", ErrInvalidArgument) + } + if strings.HasPrefix(subject, ".") || strings.HasSuffix(subject, ".") || strings.Contains(subject, "..") { + return fmt.Errorf("bus: subject %q contains an empty token: %w", subject, ErrInvalidArgument) + } + return nil +} + +// validatePattern rejects an empty pattern, one with an empty token, or one +// where the ">" wildcard is not the final token. +func validatePattern(pattern string) error { + if pattern == "" { + return fmt.Errorf("bus: pattern must not be empty: %w", ErrInvalidArgument) + } + if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") || strings.Contains(pattern, "..") { + return fmt.Errorf("bus: pattern %q contains an empty token: %w", pattern, ErrInvalidArgument) + } + tokens := strings.Split(pattern, ".") + for i, tok := range tokens { + if tok == ">" && i != len(tokens)-1 { + return fmt.Errorf("bus: pattern %q: '>' wildcard must be the last token: %w", pattern, ErrInvalidArgument) + } + } + return nil +}