// Package events is the Controller's in-memory pub-sub for live agent // events (instance state, app state, player, log). The Panel service's // StreamEvents RPC subscribes; agent message handlers publish. Subscribers // have bounded buffers and are dropped rather than back-pressuring the // whole pipeline if they can't keep up. package events import ( "sync" "sync/atomic" panelv1 "github.com/dbledeez/panel/proto/panel/v1" ) // Filter narrows which events a subscriber receives. Empty string on a // field means "no filter on that field". type Filter struct { InstanceID string AgentID string // ExcludeLogs drops Event_Log payloads for this subscriber. The global // dashboard stream sets this: log lines are by far the bulk of event // volume (a 7DTD boot is thousands of lines in seconds), the dashboard // has no DOM target for them, and shipping them anyway is what // overflowed the subscriber buffer (dropped events → stale status) and // kept the browser main thread busy. Consoles + readiness scanners use // per-instance subscriptions, which keep their logs. ExcludeLogs bool } // Subscription is the subscriber's handle. Call Close when done. type Subscription struct { bus *Bus id int64 filter Filter ch chan *panelv1.Event // sch is non-nil only for SubscribeStamped subscribers; exactly one of // ch/sch carries events for a given subscription. sch chan Stamped dropped atomic.Uint64 closed atomic.Bool } // Events returns the receive channel. Consumer should range over it. func (s *Subscription) Events() <-chan *panelv1.Event { return s.ch } // Stamped returns the id-stamped receive channel. Only valid on // subscriptions created via SubscribeStamped (nil otherwise). func (s *Subscription) Stamped() <-chan Stamped { return s.sch } // Dropped returns the number of events that were dropped because the // subscriber's buffer was full. func (s *Subscription) Dropped() uint64 { return s.dropped.Load() } // Close unsubscribes and closes the channel. Safe to call multiple times. func (s *Subscription) Close() { if !s.closed.CompareAndSwap(false, true) { return } s.bus.unsubscribe(s.id) if s.sch != nil { close(s.sch) } else { close(s.ch) } } // Bus is a concurrency-safe event fan-out with per-subscriber bounded // buffers. Subscribers register with optional filters; publishers deliver // each matching event non-blocking (drop on full buffer). type Bus struct { mu sync.RWMutex next int64 subs map[int64]*Subscription bufCap int // ring retains the most recent events (with monotonic ids) so SSE // clients can resume via Last-Event-ID. See ring.go. ring *replayRing // droppedTotal counts every event dropped on a full subscriber buffer, // across all subscribers (including since-closed ones). Metrics surface. droppedTotal atomic.Uint64 } // New creates a Bus. bufCap is the per-subscriber channel buffer. // // The default is intentionally generous: a busy install/download (SteamCMD // preallocation, large Minecraft world seed, etc.) can emit 40+ log lines // per second per instance. At the old default of 256, a browser subscriber // that paused for half a second could lose dozens of intermediate events — // the user saw "0 → 88% → 7.11%" jumps because the progress-bar frames // between them hit the default: branch in Publish. func New(bufCap int) *Bus { if bufCap <= 0 { bufCap = 4096 } return &Bus{subs: map[int64]*Subscription{}, bufCap: bufCap, ring: newReplayRing(DefaultRingCap)} } // Subscribe returns a new Subscription. Call Sub.Close when done — typically // via defer from the stream handler. func (b *Bus) Subscribe(filter Filter) *Subscription { b.mu.Lock() defer b.mu.Unlock() b.next++ s := &Subscription{ bus: b, id: b.next, filter: filter, ch: make(chan *panelv1.Event, b.bufCap), } b.subs[s.id] = s return s } // SubscribeStamped is Subscribe, but the subscription's channel carries the // event's monotonic replay id alongside the event (Stamped()). Used by the // SSE handler to emit `id:` lines for Last-Event-ID resume. func (b *Bus) SubscribeStamped(filter Filter) *Subscription { b.mu.Lock() defer b.mu.Unlock() b.next++ s := &Subscription{ bus: b, id: b.next, filter: filter, sch: make(chan Stamped, b.bufCap), } b.subs[s.id] = s return s } // Replay returns the retained events with ID > afterID that match the // filter, oldest first. ok=false signals the resume point was overwritten // (a gap precedes the returned events) — callers should resync via snapshot. func (b *Bus) Replay(afterID uint64, f Filter) ([]Stamped, bool) { all, ok := b.ring.since(afterID) out := all[:0] for _, s := range all { if match(f, s.Event) { out = append(out, s) } } return out, ok } // LastID returns the id of the most recently published event (0 if none). func (b *Bus) LastID() uint64 { return b.ring.lastID() } // DroppedTotal returns the process-lifetime count of events dropped on // full subscriber buffers. func (b *Bus) DroppedTotal() uint64 { return b.droppedTotal.Load() } func (b *Bus) unsubscribe(id int64) { b.mu.Lock() delete(b.subs, id) b.mu.Unlock() } // Publish fans the event to every matching subscriber. Non-blocking — // slow subscribers lose events rather than back-pressuring publishers. func (b *Bus) Publish(e *panelv1.Event) { // Stamp + retain first so replay ids are assigned in publish order. id := b.ring.append(e) b.mu.RLock() subs := make([]*Subscription, 0, len(b.subs)) for _, s := range b.subs { if match(s.filter, e) { subs = append(subs, s) } } b.mu.RUnlock() for _, s := range subs { if s.sch != nil { select { case s.sch <- Stamped{ID: id, Event: e}: default: s.dropped.Add(1) b.droppedTotal.Add(1) } continue } select { case s.ch <- e: default: s.dropped.Add(1) b.droppedTotal.Add(1) } } } // SubscriberCount is useful for metrics / debug. func (b *Bus) SubscriberCount() int { b.mu.RLock() defer b.mu.RUnlock() return len(b.subs) } func match(f Filter, e *panelv1.Event) bool { if f.AgentID != "" && f.AgentID != e.AgentId { return false } if f.InstanceID != "" { if eventInstanceID(e) != f.InstanceID { return false } } if f.ExcludeLogs { if _, isLog := e.Payload.(*panelv1.Event_Log); isLog { return false } } return true } func eventInstanceID(e *panelv1.Event) string { switch p := e.Payload.(type) { case *panelv1.Event_InstanceState: return p.InstanceState.InstanceId case *panelv1.Event_AppState: return p.AppState.InstanceId case *panelv1.Event_Player: return p.Player.InstanceId case *panelv1.Event_Log: return p.Log.InstanceId case *panelv1.Event_InstanceStats: return p.InstanceStats.InstanceId } return "" }