package application import ( "context" "errors" "fmt" "sync" ) var ( ErrEventRelayClosed = errors.New("application event relay closed") ErrEventRelayInvalid = errors.New("application event relay is invalid") ) // EventRelay is a bounded FIFO between background event pumps and the UI frame. // Submit applies lossless backpressure; Drain must only run on the UI goroutine. type EventRelay struct { events chan Event slots chan struct{} done chan struct{} mu sync.Mutex closed bool closeOnce sync.Once } // NewEventRelay creates a relay with a strictly positive bounded capacity. func NewEventRelay(capacity int) (*EventRelay, error) { if capacity <= 0 { return nil, fmt.Errorf( "%w: capacity must be positive", ErrEventRelayInvalid, ) } relay := &EventRelay{ events: make(chan Event, capacity), slots: make(chan struct{}, capacity), done: make(chan struct{}), } for index := 0; index < capacity; index++ { relay.slots <- struct{}{} } return relay, nil } // Submit queues one event or returns when the context/relay closes. func (relay *EventRelay) Submit(ctx context.Context, event Event) error { if relay == nil { return fmt.Errorf("%w: nil relay", ErrEventRelayInvalid) } if !event.Type.Valid() { return fmt.Errorf( "%w: unknown type %q", ErrInvalidEvent, event.Type, ) } select { case <-relay.done: return ErrEventRelayClosed case <-ctx.Done(): return ctx.Err() case <-relay.slots: } relay.mu.Lock() defer relay.mu.Unlock() if relay.closed { relay.slots <- struct{}{} return ErrEventRelayClosed } if err := ctx.Err(); err != nil { relay.slots <- struct{}{} return err } relay.events <- event return nil } // Drain applies the events present at entry without extending a UI frame forever. func (relay *EventRelay) Drain(apply func(Event) error) error { if relay == nil || apply == nil { return fmt.Errorf("%w: nil relay or apply function", ErrEventRelayInvalid) } limit := len(relay.events) var applyErrors []error for index := 0; index < limit; index++ { select { case event := <-relay.events: relay.slots <- struct{}{} if err := apply(event); err != nil { applyErrors = append(applyErrors, err) } default: return errors.Join(applyErrors...) } } return errors.Join(applyErrors...) } // Close unblocks pending submissions. Queued events remain available to Drain. func (relay *EventRelay) Close() { if relay == nil { return } relay.closeOnce.Do(func() { relay.mu.Lock() relay.closed = true close(relay.done) relay.mu.Unlock() }) } // PumpEvents forwards application events to a relay and requests a UI frame. // invalidate may be called concurrently; no UI state may be mutated here. func PumpEvents( ctx context.Context, events <-chan Event, relay *EventRelay, invalidate func(), ) error { if events == nil || relay == nil || invalidate == nil { return fmt.Errorf("%w: incomplete event pump", ErrEventRelayInvalid) } for { select { case <-ctx.Done(): return ctx.Err() case event, open := <-events: if !open { return nil } if err := relay.Submit(ctx, event); err != nil { return err } invalidate() } } }