T3.2: Add app event types and observer dispatch

Add src/app/events.go: a sealed Event interface with three concrete
types (JobChanged, RunRecorded, SchedulerStateChanged), an Observer
interface plus ObserverFunc adapter, and Subscribe/emit on the Service.
This replaces the scheduler's single onChange callback with typed
events the UI can exhaustively type-switch over.

Dispatch is serialized by a dedicated dispatchMu (separate from the
state lock): observers never run concurrently, emit must be called
without holding s.mu so observers can read Service state, and observers
must not re-enter an emitting method. emit is wired to mutating ops in
T3.3. Adds tests for ordered multi-observer delivery, empty-observer
no-op, and observer-reads-state-without-deadlock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-19 07:39:34 +03:00
parent 9931ec1237
commit 5e51381b7a
4 changed files with 157 additions and 1 deletions
+91
View File
@@ -0,0 +1,91 @@
package app
import "gitea.mixdep.ru/mix/gosentry/src/domain"
// Event is something the Service did to its state that observers may want to
// react to. It is a sealed interface: the concrete types in this file are the
// only implementations (enforced by the unexported isEvent marker), so a UI
// listener can exhaustively type-switch over them and the compiler will flag a
// new event type that a switch forgot to handle.
//
// Events replace the old single onChange callback. Instead of the scheduler
// reaching into the GUI, the Service emits typed events and the UI subscribes —
// the UI's listener becomes the one place that touches widgets.
type Event interface {
isEvent()
}
// JobChanged signals that a job's durable config or transient runtime changed:
// created, edited, deleted, enabled/disabled, or a status transition such as a
// run starting. Observers should re-read the affected state through the Service
// (Jobs/Runtime) rather than expect a payload snapshot — that keeps the event
// small and avoids handing out stale copies.
//
// JobID identifies the affected job. A zero JobID means a broad change (for
// example a delete, or a global pause that touched every job) and observers
// should refresh their whole view.
type JobChanged struct {
JobID int
}
// RunRecorded signals that a job run finished and produced a RunRecord. It
// carries the record by value because the record is an immutable result that
// observers append to history; there is nothing for them to re-read.
type RunRecorded struct {
Record domain.RunRecord
}
// SchedulerStateChanged signals that the global scheduler pause state flipped.
// The UI uses it to update the pause/resume control and status text.
type SchedulerStateChanged struct {
Paused bool
}
func (JobChanged) isEvent() {}
func (RunRecorded) isEvent() {}
func (SchedulerStateChanged) isEvent() {}
// Observer receives events emitted by the Service. OnEvent is the single
// reaction point; the UI implements it and marshals any widget work onto the
// main thread (fyne.Do) itself — the Service knows nothing about Fyne.
type Observer interface {
OnEvent(Event)
}
// ObserverFunc adapts a plain function to the Observer interface, so callers can
// subscribe a closure without declaring a type.
type ObserverFunc func(Event)
// OnEvent calls the wrapped function.
func (f ObserverFunc) OnEvent(event Event) { f(event) }
// Subscribe registers an observer to receive every subsequently emitted event.
// Registration is expected during setup, before the scheduler starts, but is
// guarded so it is safe at any time.
func (s *Service) Subscribe(observer Observer) {
s.dispatchMu.Lock()
defer s.dispatchMu.Unlock()
s.observers = append(s.observers, observer)
}
// emit delivers an event to every registered observer.
//
// Single-threaded dispatch contract:
// - emit holds dispatchMu for the whole dispatch, so observers are never
// invoked concurrently and never overlap with each other or with Subscribe.
// Each observer sees events one at a time, in emit order.
// - emit must be called WITHOUT holding s.mu. The Service computes a state
// change under mu, releases it, then emits — so an observer is free to call
// back into read methods (Jobs/Runtime) without deadlocking on the state
// lock.
// - An observer must NOT call back into a Service method that emits (directly
// or indirectly): dispatchMu is non-reentrant, so re-entrant emission would
// deadlock. Observers react and return quickly; long or UI work is the
// observer's own responsibility to defer (e.g. fyne.Do).
func (s *Service) emit(event Event) {
s.dispatchMu.Lock()
defer s.dispatchMu.Unlock()
for _, observer := range s.observers {
observer.OnEvent(event)
}
}