Part 2 of Trunking Engine, a 12-part deep dive into the “brain” of
GopherTrunk. Part 1
introduced the single-goroutine select loop and its one rule — publish, never
call outward. This post takes apart the thing it publishes *on: the event bus,
the 60-line file that lets a dozen subsystems watch the engine without the engine
knowing any of them exist.*
TL;DR:
internal/events.Busis an in-process, typed pub/sub bus. Every event is anEvent{Kind, Timestamp, Payload}whereKindis a string type andPayloadisany. Subscribers get a buffered Go channel;Publishdoes a non-blocking send to each, and a subscriber whose buffer is full drops the event rather than stalling the publisher. That one design choice — drop, don’t block — is what lets the recorder, the web UI, the metrics exporter, and the Broadcastify uploader all hang off the engine without any of them being able to wedge the decode path.
Key takeaways
- The bus is typed by convention: a
Kindconstant tells a subscriber which concrete struct to type-assertPayloadinto. Add an event kind and its payload type; nothing else changes. - Delivery is asynchronous and lossy under back-pressure. A slow subscriber drops its own events; it can never apply back-pressure to the publisher or the other subscribers.
- Subscribers are fully decoupled — the engine holds no reference to any of
them, only to the
Bus. This is the observer pattern applied at subsystem scale. - An in-process bus beats direct calls here because it makes the fanout 1-to-N, reconfigurable, and testable without turning the engine into a registry of callbacks.
Cheat sheet
| Symbol | Where | One-line role |
|---|---|---|
Kind |
bus.go |
string type naming an event class ("grant", "call.start", …) |
Event{Kind, Timestamp, Payload} |
bus.go |
one message; Payload any is asserted by Kind |
Bus |
bus.go |
the fanout — a map of subscriber id → buffered channel |
NewBus(buffer int) |
bus.go |
construct; per-subscriber buffer defaults to 64 |
(*Bus).Subscribe() *Subscription |
bus.go |
returns a read-only C <-chan Event |
(*Bus).Publish(e Event) int |
bus.go |
non-blocking fanout; returns the drop count |
(*Subscription).Close() |
bus.go |
unregister and close the channel |
In this post
- The
Kindstring type and the bigconstblock that enumerates the system’s vocabulary of events. - The
Eventshape —Kind+Payload any— and why the payload is untyped. - Subscribe / Publish — how the fanout is wired with a map of channels.
- Overflow protection — the non-blocking
selectthat makes a slow subscriber drop instead of stall. - Why a bus at all — the design principle, and how it shaped the Go.
The vocabulary: Kind and the const block
The bus does not know anything about trunking. It knows about events, and an event’s class is a plain string:
// internal/events/bus.go
type Kind string
const (
KindCallStart Kind = "call.start"
KindCallEnd Kind = "call.end"
KindCallComplete Kind = "call.complete"
KindCallSegment Kind = "call.segment"
KindGrant Kind = "grant"
KindPatch Kind = "patch"
KindTalkerAlias Kind = "talker.alias"
KindCallEncryption Kind = "call.encryption"
KindCallSourceUpdate Kind = "call.source"
KindAffiliation Kind = "affiliation"
// …and dozens more: cc.locked, sdr.attached, pager.message, adsb.aircraft…
)
That const block is the whole system’s event vocabulary in one place — from the
control-channel life-cycle (KindCCLocked, KindCCLost) through the call
life-cycle (KindGrant → KindCallStart → KindCallEnd → KindCallComplete)
to the long tail of decoder outputs that have nothing to do with trunking at all
(KindPagerMessage, KindAISMessage, KindAircraftReport). They all share one
bus because they all share one shape.
A few of these are the spine of this series and worth naming now. KindGrant
(Part 3) is
the engine’s only input. KindCallStart / KindCallEnd are its primary
outputs. KindPatch
(Part 9),
KindCallEncryption
(Part 11),
and KindCallSourceUpdate
(Part 7)
are the enrichment events the engine both subscribes to and republishes — the
loop reads them off the bus, backfills the bound call, and publishes an enriched
copy so downstream consumers see the update live.
The Event shape
An event is deliberately small:
// internal/events/bus.go
type Event struct {
Kind Kind
Timestamp time.Time
Payload any
}
Payload is any (Go’s alias for interface{}), and that is a considered
trade-off. A statically-typed bus — one channel per event type — would give the
compiler a say, but it would also mean the engine, the API layer, and every
decoder shared a growing zoo of channel types and a fanout struct that had to be
edited for every new event. Instead, the contract is by convention: a
KindCallStart event’s Payload is always a trunking.CallStart, a
KindGrant’s is always a trunking.Grant. Subscribers assert it, exactly as
the engine’s own loop does in Part 1:
// a subscriber's receive side (shape)
case ev := <-sub.C:
switch ev.Kind {
case events.KindCallStart:
cs, ok := ev.Payload.(trunking.CallStart) // Kind tells you the type
if !ok { continue }
recorder.Begin(cs)
}
The Kind is the type tag. The pairing is documented right on each constant in
bus.go — the doc comment for KindCallSourceUpdate, for instance, names its
payload as trunking.CallSourceUpdate and explains exactly when the voice
composer emits it. That discipline — every Kind names its payload type in a
comment — is what keeps an untyped any from becoming a guessing game.
Note also that events imports nothing from trunking. Payload structs that
both packages need — DecodeError, ChannelPower, the DMR band-plan types —
are declared in events with primitive fields precisely so the bus stays free
of a trunking import and no import cycle forms. The bus is the bottom of the
dependency graph, and it stays there.
Subscribe and Publish
The Bus itself is a map of subscriber id to channel, under an RWMutex:
// internal/events/bus.go
type Bus struct {
mu sync.RWMutex
subs map[uint64]chan Event
nextID atomic.Uint64
buffer int
closed bool
}
func (b *Bus) Subscribe() *Subscription {
b.mu.Lock()
defer b.mu.Unlock()
id := b.nextID.Add(1)
ch := make(chan Event, b.buffer) // buffered, default 64
b.subs[id] = ch
return &Subscription{id: id, C: ch, b: b}
}
Subscribe mints a fresh buffered channel and hands back a Subscription whose
C field is a receive-only <-chan Event — a subscriber can read but never
send or close it. Close() deletes the entry and closes the channel, so a
consumer’s for ev := range sub.C naturally terminates. The RWMutex lets many
Publish calls fan out concurrently (read lock) while Subscribe/Close take
the write lock — subscription churn is rare, publishes are hot.
Overflow protection: drop, don’t block
Here is the load-bearing code — the entire reason the bus exists in this form:
// internal/events/bus.go
// Publish delivers e to every subscriber. Slow subscribers drop the event
// rather than blocking the publisher; we count drops via the returned int.
func (b *Bus) Publish(e Event) int {
if e.Timestamp.IsZero() {
e.Timestamp = time.Now()
}
b.mu.RLock()
defer b.mu.RUnlock()
dropped := 0
for _, ch := range b.subs {
select {
case ch <- e: // room in the buffer → deliver
default: // buffer full → drop, count it
dropped++
}
}
return dropped
}
The select with a default is a non-blocking send. If a subscriber’s
64-deep buffer has room, the event lands. If it’s full — because that subscriber
is slow — the default arm fires, the event is dropped for that subscriber
only, and Publish moves on. Publish returns the number of drops so a caller
can surface it (the engine logs a metric when a publish drops).
Why this matters concretely: the web UI’s live feed goes over a WebSocket to a
browser that might be on hotel Wi-Fi. Without overflow protection, a blocked
WebSocket write would back up its subscriber channel, block Publish, and block
the engine’s select loop — and now a laggy browser has stalled call recording
on the host. With it, the slow WebSocket simply misses a few live-view updates
while the recorder, the call log, and the decode path run at full speed. Each
subscriber’s back-pressure is contained entirely within its own buffer.
This is a real trade-off, not a free lunch: the bus is lossy under load. It is the right default for a live-view/telemetry fanout where missing an update is survivable, and it is explicitly the wrong tool for anything that must not drop — durable call metadata is written by a subscriber that persists to SQLite, and if that subscriber ever fell behind, the fix is a bigger buffer or a dedicated queue, not making the bus block.
default arm of the send select is the whole design: a full buffer drops one subscriber's event instead of stalling the publisher and everyone else.Why a bus instead of direct calls
How that principle shaped the Go code
The engine could, in principle, hold a recorder, a callLog, a metrics, and
a wsHub, and call each in turn when a call starts. It deliberately doesn’t, and
the bus is why:
- One-to-N without a registry. Direct calls make the engine own a list of
every consumer and the order it calls them. The bus inverts that: consumers
register themselves with
Subscribe(), and the engine’s fanout is a singlePublish. Adding the Broadcastify uploader (aKindCallCompletesubscriber) touched zero lines of engine code. - Fault isolation. A direct call means a panic or a slow write in one
consumer is on the engine’s goroutine. A bus means each subscriber drains its
own channel on its own goroutine, and the non-blocking
Publishmeans even a wedged one can’t reach back. - Testability. Because the engine’s only output is
Publish, a test subscribes a fake channel, feeds a syntheticGrant, and asserts aKindCallStartcomes back — the harness we build in Part 12. No mocks of four subsystems, just one channel. - Uniformity. The pager decoder, the ADS-B decoder, and the trunking engine
all publish onto the same bus with the same
Eventshape, so the API layer’s SSE/WebSocket bridge is one subscriber that forwards everything, not one adapter per producer.
The cost is the untyped Payload and the by-convention type contract. In a
Go 1.25 codebase without sum types, that is the pragmatic price for a fanout that
every subsystem can share.
Where this goes next
With the bus understood, we can follow a single message through it.
Part 3
opens the Grant — the payload of the one event the engine treats as input —
and the duplicate-grant guard that keeps a repeated control-channel TSBK from
binding a second radio. From there the series follows that grant into the voice
pool (Part 4)
and the priority policy
(Part 5).
FAQ
Is the event bus thread-safe?
Yes. The subscriber map is guarded by an RWMutex: Publish takes the read lock
so many publishes fan out concurrently, while Subscribe and Close take the
write lock. Each subscriber’s channel is single-producer (the bus) and
single-consumer (the subscriber), so no further synchronization is needed on the
channel itself.
What happens if a subscriber is too slow?
It drops events. Publish does a non-blocking send into each subscriber’s
buffered channel; if the buffer (default 64) is full, that event is dropped for
that subscriber and counted in Publish’s return value. A slow subscriber
degrades only its own feed — it can never block the publisher or the other
subscribers.
Why is Payload typed as any instead of a concrete type?
So one bus can carry every event class in the system without the events
package depending on trunking (or any decoder). The Kind constant names the
concrete payload type by convention, documented on each constant, and
subscribers type-assert it. It trades compile-time checking for a fanout every
subsystem can share.
Does publishing block until subscribers process the event?
No. Delivery is asynchronous: Publish hands the event to each subscriber’s
buffer and returns immediately. Subscribers process on their own goroutines. This
is what keeps the engine’s select loop from ever waiting on a consumer.
How do I add a new event type?
Add a Kind constant to the const block in bus.go (with a doc comment naming
its payload type), define the payload struct wherever it naturally lives, publish
it from the producer, and subscribe where you need it. No change to Bus,
Publish, or any existing subscriber.
Series navigation
Part 2 of 12 · ← Part 1: From Grant to Recorded Call · Next → Part 3: Grants — The Engine’s Only Input