diff --git a/internal/flinger/flinger.go b/internal/flinger/flinger.go index 7fb7ff5..fdd3e07 100644 --- a/internal/flinger/flinger.go +++ b/internal/flinger/flinger.go @@ -15,6 +15,7 @@ import ( "io" "log" "strconv" + "sync" "sync/atomic" "time" @@ -91,6 +92,9 @@ type Status struct { Frames int64 Dropped int64 Conns int + // Err is the most recent runtime error encountered (capture, encode, + // packet or audio), or nil if the stream is healthy. + Err error } // Engine owns the capture, encode and send pipeline. @@ -106,7 +110,16 @@ type Engine struct { start time.Time stop chan struct{} - frames atomic.Int64 + frames atomic.Int64 + errMu sync.RWMutex + lastErr error +} + +// setErr records the most recent runtime error. Pass nil to clear it. +func (e *Engine) setErr(err error) { + e.errMu.Lock() + e.lastErr = err + e.errMu.Unlock() } // New creates an engine from cfg. Capture is opened eagerly so that @@ -208,11 +221,15 @@ func (e *Engine) Stop() { // Status returns a snapshot of the running engine. func (e *Engine) Status() Status { + e.errMu.RLock() + err := e.lastErr + e.errMu.RUnlock() return Status{ Running: e.stop != nil, Frames: e.frames.Load(), Dropped: e.sender.Dropped(), Conns: e.sender.NumConns(), + Err: err, } } @@ -237,6 +254,7 @@ func (e *Engine) audioLoop(src io.ReadCloser) { ts := uint64(time.Since(e.start)) packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n]) if perr != nil { + e.setErr(perr) log.Printf("flinger: wave: %v", perr) } else { e.sender.Send(packet) @@ -249,6 +267,7 @@ func (e *Engine) audioLoop(src io.ReadCloser) { default: } if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) { + e.setErr(err) log.Printf("flinger: audio: %v", err) } } @@ -269,6 +288,7 @@ func (e *Engine) videoLoop() { default: } if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) { + e.setErr(err) log.Printf("flinger: capture: %v", err) } continue @@ -285,6 +305,7 @@ func (e *Engine) videoLoop() { ts := uint64(now.Sub(e.start)) buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, e.cfg.Quality) if err != nil { + e.setErr(err) log.Printf("flinger: jpeg: %v", err) continue } @@ -296,6 +317,7 @@ func (e *Engine) videoLoop() { buf, ) if err != nil { + e.setErr(err) log.Printf("flinger: packet: %v", err) continue } diff --git a/internal/flinger/flinger_test.go b/internal/flinger/flinger_test.go index c0ba4ea..0fd06aa 100644 --- a/internal/flinger/flinger_test.go +++ b/internal/flinger/flinger_test.go @@ -1,6 +1,7 @@ package flinger import ( + "errors" "net" "testing" "time" @@ -77,6 +78,34 @@ func TestNewRejectsBadSource(t *testing.T) { } } +// TestSetErrStatus verifies runtime errors are exposed via Status and can be +// cleared. +func TestSetErrStatus(t *testing.T) { + cfg := DefaultConfig() + cfg.Source = "pattern" + cfg.Port = 19757 + + eng, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + if st := eng.Status(); st.Err != nil { + t.Fatalf("expected no error initially, got %v", st.Err) + } + + sentinel := errors.New("test capture failure") + eng.setErr(sentinel) + if st := eng.Status(); st.Err != sentinel { + t.Errorf("expected sentinel error, got %v", st.Err) + } + + eng.setErr(nil) + if st := eng.Status(); st.Err != nil { + t.Errorf("expected cleared error, got %v", st.Err) + } +} + // TestValidate rejects out-of-range values. func TestValidate(t *testing.T) { bad := []func(*Config){