refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
package bus
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
2026-04-26 08:05:10 +00:00
|
|
|
|
|
|
|
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestPublishConsume(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
msg := InboundMessage{
|
2026-04-01 12:56:48 +00:00
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat1",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user1",
|
|
|
|
|
},
|
|
|
|
|
Content: "hello",
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishInbound(ctx, msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishInbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
got, ok := <-mb.InboundChan()
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("ConsumeInbound returned ok=false")
|
|
|
|
|
}
|
|
|
|
|
if got.Content != "hello" {
|
|
|
|
|
t.Fatalf("expected content 'hello', got %q", got.Content)
|
|
|
|
|
}
|
|
|
|
|
if got.Channel != "test" {
|
|
|
|
|
t.Fatalf("expected channel 'test', got %q", got.Channel)
|
|
|
|
|
}
|
2026-04-01 05:35:18 +00:00
|
|
|
if got.Context.Channel != "test" {
|
|
|
|
|
t.Fatalf("expected context channel 'test', got %q", got.Context.Channel)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.ChatID != "chat1" {
|
|
|
|
|
t.Fatalf("expected context chat ID 'chat1', got %q", got.Context.ChatID)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.SenderID != "user1" {
|
|
|
|
|
t.Fatalf("expected context sender ID 'user1', got %q", got.Context.SenderID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
func TestPublishInbound_NormalizesContext(t *testing.T) {
|
2026-04-01 05:35:18 +00:00
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
msg := InboundMessage{
|
2026-04-01 12:56:48 +00:00
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "slack",
|
|
|
|
|
Account: "workspace-a",
|
|
|
|
|
ChatID: "C456/1712",
|
|
|
|
|
ChatType: "group",
|
|
|
|
|
TopicID: "1712",
|
|
|
|
|
SpaceID: "T001",
|
|
|
|
|
SpaceType: "team",
|
|
|
|
|
SenderID: "U123",
|
|
|
|
|
MessageID: "1712.01",
|
|
|
|
|
ReplyToMessageID: "1700.01",
|
|
|
|
|
Mentioned: true,
|
2026-04-01 05:35:18 +00:00
|
|
|
},
|
2026-04-01 12:56:48 +00:00
|
|
|
Content: "hello",
|
2026-04-01 05:35:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishInbound(context.Background(), msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishInbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := <-mb.InboundChan()
|
|
|
|
|
if got.Context.Channel != "slack" {
|
|
|
|
|
t.Fatalf("expected context channel slack, got %q", got.Context.Channel)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.Account != "workspace-a" {
|
|
|
|
|
t.Fatalf("expected context account workspace-a, got %q", got.Context.Account)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.ChatType != "group" {
|
|
|
|
|
t.Fatalf("expected context chat type group, got %q", got.Context.ChatType)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.TopicID != "1712" {
|
|
|
|
|
t.Fatalf("expected topic 1712, got %q", got.Context.TopicID)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.SpaceType != "team" || got.Context.SpaceID != "T001" {
|
|
|
|
|
t.Fatalf("expected team space T001, got %q/%q", got.Context.SpaceType, got.Context.SpaceID)
|
|
|
|
|
}
|
|
|
|
|
if !got.Context.Mentioned {
|
|
|
|
|
t.Fatal("expected mentioned=true in context")
|
|
|
|
|
}
|
|
|
|
|
if got.Context.ReplyToMessageID != "1700.01" {
|
|
|
|
|
t.Fatalf("expected reply_to_message_id 1700.01, got %q", got.Context.ReplyToMessageID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
func TestPublishInbound_MirrorsContextIntoConvenienceFields(t *testing.T) {
|
2026-04-01 05:35:18 +00:00
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
msg := InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "telegram",
|
|
|
|
|
Account: "bot-a",
|
|
|
|
|
ChatID: "-1001",
|
|
|
|
|
ChatType: "group",
|
|
|
|
|
TopicID: "42",
|
|
|
|
|
SpaceID: "guild-9",
|
|
|
|
|
SpaceType: "guild",
|
|
|
|
|
SenderID: "user-1",
|
|
|
|
|
MessageID: "777",
|
|
|
|
|
Mentioned: true,
|
|
|
|
|
ReplyToMessageID: "666",
|
|
|
|
|
},
|
|
|
|
|
Content: "hi",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishInbound(context.Background(), msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishInbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := <-mb.InboundChan()
|
|
|
|
|
if got.Channel != "telegram" {
|
|
|
|
|
t.Fatalf("expected legacy channel telegram, got %q", got.Channel)
|
|
|
|
|
}
|
|
|
|
|
if got.ChatID != "-1001" {
|
|
|
|
|
t.Fatalf("expected legacy chat ID -1001, got %q", got.ChatID)
|
|
|
|
|
}
|
|
|
|
|
if got.SenderID != "user-1" {
|
|
|
|
|
t.Fatalf("expected legacy sender ID user-1, got %q", got.SenderID)
|
|
|
|
|
}
|
|
|
|
|
if got.MessageID != "777" {
|
|
|
|
|
t.Fatalf("expected legacy message ID 777, got %q", got.MessageID)
|
|
|
|
|
}
|
2026-04-01 12:56:48 +00:00
|
|
|
if got.Context.Account != "bot-a" || got.Context.SpaceID != "guild-9" || got.Context.TopicID != "42" {
|
|
|
|
|
t.Fatalf("unexpected normalized context: %+v", got.Context)
|
2026-04-01 05:35:18 +00:00
|
|
|
}
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 05:25:07 +00:00
|
|
|
func TestPublishInbound_BackfillsContextFromLegacyFields(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
msg := InboundMessage{
|
|
|
|
|
Channel: "pico",
|
|
|
|
|
ChatID: "session-1",
|
|
|
|
|
SenderID: "user-1",
|
|
|
|
|
MessageID: "msg-1",
|
|
|
|
|
Content: "hello",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishInbound(context.Background(), msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishInbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := <-mb.InboundChan()
|
|
|
|
|
if got.Context.Channel != "pico" {
|
|
|
|
|
t.Fatalf("expected context channel pico, got %q", got.Context.Channel)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.ChatID != "session-1" {
|
|
|
|
|
t.Fatalf("expected context chat ID session-1, got %q", got.Context.ChatID)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.SenderID != "user-1" {
|
|
|
|
|
t.Fatalf("expected context sender ID user-1, got %q", got.Context.SenderID)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.MessageID != "msg-1" {
|
|
|
|
|
t.Fatalf("expected context message ID msg-1, got %q", got.Context.MessageID)
|
|
|
|
|
}
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 08:05:10 +00:00
|
|
|
func TestMessageBusPublishesRuntimeFailureAndCloseEvents(t *testing.T) {
|
|
|
|
|
eventBus := runtimeevents.NewBus()
|
|
|
|
|
defer func() {
|
|
|
|
|
if err := eventBus.Close(); err != nil {
|
|
|
|
|
t.Errorf("event bus close failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
_, eventsCh, err := eventBus.Channel().OfKind(
|
|
|
|
|
runtimeevents.KindBusPublishFailed,
|
|
|
|
|
runtimeevents.KindBusCloseStarted,
|
|
|
|
|
runtimeevents.KindBusCloseDrained,
|
|
|
|
|
runtimeevents.KindBusCloseCompleted,
|
|
|
|
|
).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "bus-events", Buffer: 4})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("SubscribeChan failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
mb.SetEventPublisher(eventBus)
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishInbound(context.Background(), InboundMessage{}); err == nil {
|
|
|
|
|
t.Fatal("expected PublishInbound to fail")
|
|
|
|
|
}
|
|
|
|
|
failed := receiveBusRuntimeEvent(t, eventsCh)
|
|
|
|
|
if failed.Kind != runtimeevents.KindBusPublishFailed ||
|
|
|
|
|
failed.Source.Name != "inbound" ||
|
|
|
|
|
failed.Severity != runtimeevents.SeverityError {
|
|
|
|
|
t.Fatalf("publish failed event = %+v", failed)
|
|
|
|
|
}
|
2026-04-27 05:09:03 +00:00
|
|
|
if failed.Attrs["stream"] != "inbound" || failed.Attrs["error"] == "" {
|
|
|
|
|
t.Fatalf("publish failed attrs = %#v, want stream and error", failed.Attrs)
|
|
|
|
|
}
|
2026-04-26 08:05:10 +00:00
|
|
|
|
|
|
|
|
if err := mb.PublishOutbound(context.Background(), OutboundMessage{
|
|
|
|
|
Context: NewOutboundContext("telegram", "chat-1", ""),
|
|
|
|
|
Content: "queued",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("PublishOutbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
mb.Close()
|
|
|
|
|
|
|
|
|
|
seen := map[runtimeevents.Kind]bool{}
|
2026-04-27 05:09:03 +00:00
|
|
|
var drainedAttrs map[string]any
|
2026-04-26 08:05:10 +00:00
|
|
|
for range 3 {
|
|
|
|
|
evt := receiveBusRuntimeEvent(t, eventsCh)
|
|
|
|
|
seen[evt.Kind] = true
|
2026-04-27 05:09:03 +00:00
|
|
|
if evt.Kind == runtimeevents.KindBusCloseDrained {
|
|
|
|
|
drainedAttrs = evt.Attrs
|
|
|
|
|
}
|
2026-04-26 08:05:10 +00:00
|
|
|
}
|
|
|
|
|
for _, kind := range []runtimeevents.Kind{
|
|
|
|
|
runtimeevents.KindBusCloseStarted,
|
|
|
|
|
runtimeevents.KindBusCloseDrained,
|
|
|
|
|
runtimeevents.KindBusCloseCompleted,
|
|
|
|
|
} {
|
|
|
|
|
if !seen[kind] {
|
|
|
|
|
t.Fatalf("missing %s event, seen=%v", kind, seen)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-27 05:09:03 +00:00
|
|
|
if drainedAttrs["drained"] != 1 {
|
|
|
|
|
t.Fatalf("bus close drained attrs = %#v, want drained count", drainedAttrs)
|
|
|
|
|
}
|
2026-04-26 08:05:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func receiveBusRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case evt, ok := <-ch:
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("runtime event channel closed before expected event")
|
|
|
|
|
}
|
|
|
|
|
return evt
|
|
|
|
|
case <-time.After(time.Second):
|
|
|
|
|
t.Fatal("timed out waiting for runtime event")
|
|
|
|
|
return runtimeevents.Event{}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
func TestPublishOutboundSubscribe(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
msg := OutboundMessage{
|
2026-04-01 12:56:48 +00:00
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "telegram",
|
|
|
|
|
ChatID: "123",
|
|
|
|
|
},
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
Content: "world",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishOutbound(ctx, msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishOutbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
got, ok := <-mb.OutboundChan()
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("SubscribeOutbound returned ok=false")
|
|
|
|
|
}
|
|
|
|
|
if got.Content != "world" {
|
|
|
|
|
t.Fatalf("expected content 'world', got %q", got.Content)
|
|
|
|
|
}
|
2026-04-01 12:56:48 +00:00
|
|
|
if got.Context.Channel != "telegram" || got.Context.ChatID != "123" {
|
|
|
|
|
t.Fatalf("expected normalized outbound context, got %+v", got.Context)
|
|
|
|
|
}
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 07:23:36 +00:00
|
|
|
func TestPublishOutbound_MirrorsContextToLegacyFields(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
msg := OutboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "telegram",
|
|
|
|
|
ChatID: "chat-42",
|
|
|
|
|
ReplyToMessageID: "msg-9",
|
|
|
|
|
},
|
2026-04-07 14:12:23 +00:00
|
|
|
AgentID: "main",
|
|
|
|
|
SessionKey: "sk_v1_123",
|
|
|
|
|
Scope: &OutboundScope{
|
|
|
|
|
Version: 1,
|
|
|
|
|
AgentID: "main",
|
|
|
|
|
Channel: "telegram",
|
|
|
|
|
Account: "bot-a",
|
|
|
|
|
Dimensions: []string{"chat", "sender"},
|
|
|
|
|
Values: map[string]string{
|
|
|
|
|
"chat": "direct:chat-42",
|
|
|
|
|
"sender": "user-1",
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-01 07:23:36 +00:00
|
|
|
Content: "reply",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishOutbound(context.Background(), msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishOutbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := <-mb.OutboundChan()
|
|
|
|
|
if got.Channel != "telegram" {
|
|
|
|
|
t.Fatalf("expected legacy channel telegram, got %q", got.Channel)
|
|
|
|
|
}
|
|
|
|
|
if got.ChatID != "chat-42" {
|
|
|
|
|
t.Fatalf("expected legacy chat ID chat-42, got %q", got.ChatID)
|
|
|
|
|
}
|
|
|
|
|
if got.ReplyToMessageID != "msg-9" {
|
|
|
|
|
t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID)
|
|
|
|
|
}
|
2026-04-07 14:12:23 +00:00
|
|
|
if got.AgentID != "main" || got.SessionKey != "sk_v1_123" {
|
|
|
|
|
t.Fatalf("unexpected outbound turn metadata: agent=%q session=%q", got.AgentID, got.SessionKey)
|
|
|
|
|
}
|
|
|
|
|
if got.Scope == nil || got.Scope.AgentID != "main" || got.Scope.Values["chat"] != "direct:chat-42" {
|
|
|
|
|
t.Fatalf("unexpected outbound scope: %+v", got.Scope)
|
|
|
|
|
}
|
2026-04-01 07:23:36 +00:00
|
|
|
if got.Context.Channel != "telegram" || got.Context.ChatID != "chat-42" {
|
|
|
|
|
t.Fatalf("unexpected outbound context: %+v", got.Context)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 04:35:27 +00:00
|
|
|
func TestPublishOutbound_PreservesExplicitReplyToMessageID(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
msg := OutboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "telegram",
|
|
|
|
|
ChatID: "chat-42",
|
|
|
|
|
},
|
|
|
|
|
ReplyToMessageID: "msg-9",
|
|
|
|
|
Content: "reply",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishOutbound(context.Background(), msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishOutbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := <-mb.OutboundChan()
|
|
|
|
|
if got.ReplyToMessageID != "msg-9" {
|
|
|
|
|
t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.ReplyToMessageID != "msg-9" {
|
|
|
|
|
t.Fatalf("expected context reply_to_message_id msg-9, got %q", got.Context.ReplyToMessageID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:51:44 +00:00
|
|
|
func TestPublishOutbound_PreservesExplicitReplyToMessageIDWhenContextReplyIsBlank(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
msg := OutboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "telegram",
|
|
|
|
|
ChatID: "chat-42",
|
|
|
|
|
ReplyToMessageID: " ",
|
|
|
|
|
},
|
|
|
|
|
ReplyToMessageID: "msg-9",
|
|
|
|
|
Content: "reply",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishOutbound(context.Background(), msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishOutbound failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := <-mb.OutboundChan()
|
|
|
|
|
if got.ReplyToMessageID != "msg-9" {
|
|
|
|
|
t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID)
|
|
|
|
|
}
|
|
|
|
|
if got.Context.ReplyToMessageID != "msg-9" {
|
|
|
|
|
t.Fatalf("expected context reply_to_message_id msg-9, got %q", got.Context.ReplyToMessageID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 07:23:36 +00:00
|
|
|
func TestPublishOutboundMedia_MirrorsContextToLegacyFields(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
msg := OutboundMediaMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "slack",
|
|
|
|
|
ChatID: "C001",
|
|
|
|
|
},
|
2026-04-07 14:12:23 +00:00
|
|
|
AgentID: "support",
|
|
|
|
|
SessionKey: "sk_v1_media",
|
|
|
|
|
Scope: &OutboundScope{
|
|
|
|
|
Version: 1,
|
|
|
|
|
AgentID: "support",
|
|
|
|
|
Channel: "slack",
|
|
|
|
|
Dimensions: []string{"chat"},
|
|
|
|
|
Values: map[string]string{
|
|
|
|
|
"chat": "channel:c001",
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-01 07:23:36 +00:00
|
|
|
Parts: []MediaPart{{Type: "image", Ref: "media://1"}},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishOutboundMedia(context.Background(), msg); err != nil {
|
|
|
|
|
t.Fatalf("PublishOutboundMedia failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := <-mb.OutboundMediaChan()
|
|
|
|
|
if got.Channel != "slack" {
|
|
|
|
|
t.Fatalf("expected legacy channel slack, got %q", got.Channel)
|
|
|
|
|
}
|
|
|
|
|
if got.ChatID != "C001" {
|
|
|
|
|
t.Fatalf("expected legacy chat ID C001, got %q", got.ChatID)
|
|
|
|
|
}
|
2026-04-07 14:12:23 +00:00
|
|
|
if got.AgentID != "support" || got.SessionKey != "sk_v1_media" {
|
|
|
|
|
t.Fatalf("unexpected outbound media turn metadata: agent=%q session=%q", got.AgentID, got.SessionKey)
|
|
|
|
|
}
|
|
|
|
|
if got.Scope == nil || got.Scope.Values["chat"] != "channel:c001" {
|
|
|
|
|
t.Fatalf("unexpected outbound media scope: %+v", got.Scope)
|
|
|
|
|
}
|
2026-04-01 07:23:36 +00:00
|
|
|
if got.Context.Channel != "slack" || got.Context.ChatID != "C001" {
|
|
|
|
|
t.Fatalf("unexpected outbound media context: %+v", got.Context)
|
2026-04-07 13:05:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPublishAudioChunkSubscribe(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
chunk := AudioChunk{
|
|
|
|
|
SessionID: "voice-1",
|
|
|
|
|
SpeakerID: "speaker-1",
|
|
|
|
|
ChatID: "chat-1",
|
|
|
|
|
Channel: "discord",
|
|
|
|
|
Sequence: 7,
|
|
|
|
|
Format: "opus",
|
|
|
|
|
Data: []byte{0x01, 0x02},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishAudioChunk(context.Background(), chunk); err != nil {
|
|
|
|
|
t.Fatalf("PublishAudioChunk failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got, ok := <-mb.AudioChunksChan()
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("AudioChunksChan returned ok=false")
|
|
|
|
|
}
|
|
|
|
|
if got.SessionID != "voice-1" || got.Sequence != 7 {
|
|
|
|
|
t.Fatalf("unexpected audio chunk: %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPublishVoiceControlSubscribe(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
ctrl := VoiceControl{
|
|
|
|
|
SessionID: "voice-1",
|
|
|
|
|
ChatID: "chat-1",
|
|
|
|
|
Type: "command",
|
|
|
|
|
Action: "start",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mb.PublishVoiceControl(context.Background(), ctrl); err != nil {
|
|
|
|
|
t.Fatalf("PublishVoiceControl failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got, ok := <-mb.VoiceControlsChan()
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("VoiceControlsChan returned ok=false")
|
|
|
|
|
}
|
|
|
|
|
if got.Type != "command" || got.Action != "start" {
|
|
|
|
|
t.Fatalf("unexpected voice control: %+v", got)
|
2026-04-01 07:23:36 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
func TestNewOutboundContext_NormalizesReplyAddress(t *testing.T) {
|
|
|
|
|
ctx := NewOutboundContext(" telegram ", " chat-42 ", " msg-9 ")
|
|
|
|
|
if ctx.Channel != "telegram" {
|
|
|
|
|
t.Fatalf("expected channel telegram, got %q", ctx.Channel)
|
|
|
|
|
}
|
|
|
|
|
if ctx.ChatID != "chat-42" {
|
|
|
|
|
t.Fatalf("expected chat_id chat-42, got %q", ctx.ChatID)
|
|
|
|
|
}
|
|
|
|
|
if ctx.ReplyToMessageID != "msg-9" {
|
|
|
|
|
t.Fatalf("expected reply_to_message_id msg-9, got %q", ctx.ReplyToMessageID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
func TestPublishInbound_ContextCancel(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
// Fill the buffer
|
|
|
|
|
ctx := context.Background()
|
2026-02-28 04:21:54 +00:00
|
|
|
for i := range defaultBusBufferSize {
|
2026-04-01 12:56:48 +00:00
|
|
|
if err := mb.PublishInbound(ctx, InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat-fill",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user-fill",
|
|
|
|
|
},
|
|
|
|
|
Content: "fill",
|
|
|
|
|
}); err != nil {
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
t.Fatalf("fill failed at %d: %v", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 15:36:06 +00:00
|
|
|
// Now buffer is full; publish with a canceled context
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
cancelCtx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
cancel()
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
err := mb.PublishInbound(cancelCtx, InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat-overflow",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user-overflow",
|
|
|
|
|
},
|
|
|
|
|
Content: "overflow",
|
|
|
|
|
})
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
if err == nil {
|
2026-02-26 15:36:06 +00:00
|
|
|
t.Fatal("expected error from canceled context, got nil")
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
}
|
|
|
|
|
if err != context.Canceled {
|
|
|
|
|
t.Fatalf("expected context.Canceled, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPublishInbound_BusClosed(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
mb.Close()
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
err := mb.PublishInbound(context.Background(), InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat1",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user1",
|
|
|
|
|
},
|
|
|
|
|
Content: "test",
|
|
|
|
|
})
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
if err != ErrBusClosed {
|
|
|
|
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPublishOutbound_BusClosed(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
mb.Close()
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
err := mb.PublishOutbound(context.Background(), OutboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat1",
|
|
|
|
|
},
|
|
|
|
|
Content: "test",
|
|
|
|
|
})
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
if err != ErrBusClosed {
|
|
|
|
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
2026-03-17 16:12:12 +00:00
|
|
|
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
defer mb.Close()
|
|
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
for i := range defaultBusBufferSize {
|
2026-04-01 12:56:48 +00:00
|
|
|
if err := mb.PublishInbound(context.Background(), InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat-fill",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user-fill",
|
|
|
|
|
},
|
|
|
|
|
Content: "fill",
|
|
|
|
|
}); err != nil {
|
2026-03-17 16:12:12 +00:00
|
|
|
t.Fatalf("fill failed at %d: %v", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
|
|
|
defer cancel()
|
2026-04-01 12:56:48 +00:00
|
|
|
mb.PublishInbound(ctx, InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat-cancel",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user-cancel",
|
|
|
|
|
},
|
|
|
|
|
Content: "ContextCancel",
|
|
|
|
|
})
|
2026-03-17 16:12:12 +00:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
t.Log("context canceled, as expected")
|
|
|
|
|
|
|
|
|
|
case msg, ok := <-mb.InboundChan():
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected ok=false when context is canceled")
|
|
|
|
|
}
|
|
|
|
|
if msg.Content == "ContextCancel" {
|
|
|
|
|
t.Fatalf("expected content 'ContextCancel', got %q", msg.Content)
|
|
|
|
|
}
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConsumeInbound_BusClosed(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
timer := time.AfterFunc(100*time.Millisecond, func() {
|
|
|
|
|
mb.Close()
|
|
|
|
|
})
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
select {
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
t.Log("context canceled, as expected")
|
|
|
|
|
|
|
|
|
|
case _, ok := <-mb.InboundChan():
|
|
|
|
|
if ok {
|
|
|
|
|
t.Fatal("expected ok=false when context is canceled")
|
|
|
|
|
}
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSubscribeOutbound_BusClosed(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
mb.Close()
|
|
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
_, ok := <-mb.OutboundChan()
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
if ok {
|
|
|
|
|
t.Fatal("expected ok=false when bus is closed")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConcurrentPublishClose(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
const numGoroutines = 100
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
wg.Add(numGoroutines + 1)
|
|
|
|
|
|
|
|
|
|
// Spawn many goroutines trying to publish
|
2026-02-28 04:21:54 +00:00
|
|
|
for range numGoroutines {
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
go func() {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
// Use a short timeout context so we don't block forever after close
|
|
|
|
|
publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
|
|
|
|
defer cancel()
|
|
|
|
|
// Errors are expected; we just must not panic or deadlock
|
|
|
|
|
_ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"})
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Close from another goroutine
|
|
|
|
|
go func() {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
|
mb.Close()
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Must complete without deadlock
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
wg.Wait()
|
|
|
|
|
close(done)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-done:
|
|
|
|
|
// success
|
|
|
|
|
case <-time.After(5 * time.Second):
|
|
|
|
|
t.Fatal("test timed out - possible deadlock")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPublishInbound_FullBuffer(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
defer mb.Close()
|
|
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
// Fill the buffer
|
2026-02-28 04:21:54 +00:00
|
|
|
for i := range defaultBusBufferSize {
|
2026-04-01 12:56:48 +00:00
|
|
|
if err := mb.PublishInbound(ctx, InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat-fill",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user-fill",
|
|
|
|
|
},
|
|
|
|
|
Content: "fill",
|
|
|
|
|
}); err != nil {
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
t.Fatalf("fill failed at %d: %v", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Buffer is full; publish with short timeout
|
|
|
|
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
err := mb.PublishInbound(timeoutCtx, InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat-overflow",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user-overflow",
|
|
|
|
|
},
|
|
|
|
|
Content: "overflow",
|
|
|
|
|
})
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error when buffer is full and context times out")
|
|
|
|
|
}
|
|
|
|
|
if err != context.DeadlineExceeded {
|
|
|
|
|
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCloseIdempotent(t *testing.T) {
|
|
|
|
|
mb := NewMessageBus()
|
|
|
|
|
|
|
|
|
|
// Multiple Close calls must not panic
|
|
|
|
|
mb.Close()
|
|
|
|
|
mb.Close()
|
|
|
|
|
mb.Close()
|
|
|
|
|
|
|
|
|
|
// After close, publish should return ErrBusClosed
|
2026-04-01 12:56:48 +00:00
|
|
|
err := mb.PublishInbound(context.Background(), InboundMessage{
|
|
|
|
|
Context: InboundContext{
|
|
|
|
|
Channel: "test",
|
|
|
|
|
ChatID: "chat1",
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: "user1",
|
|
|
|
|
},
|
|
|
|
|
Content: "test",
|
|
|
|
|
})
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
if err != ErrBusClosed {
|
|
|
|
|
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|