feat: harden engine config, discovery and backpressure stats

- config: validate port/quality/fps/source/stream-index ranges on load
  and before engine start (flinger.Config.Validate)
- discovery: add Announce option to disable multicast (GUI checkbox,
  CLI --no-announce); absent JSON key keeps the default true
- backpressure: count dropped frames in the TCP sender, expose via
  engine Status and a live counter in the GUI status label
- docs: record M3/M4 decisions and X11 coverage via the portal backend
This commit is contained in:
2026-09-18 20:44:39 +01:00
parent cb27a0d63d
commit 2b3fd54934
8 changed files with 195 additions and 21 deletions
+45 -9
View File
@@ -48,23 +48,48 @@ type Config struct {
Audio bool
// StreamIndex selects which monitor to capture (screen source only).
StreamIndex int
// Announce controls whether the stream is advertised via UDP multicast.
// When disabled, receivers must connect by IP manually.
Announce bool
}
// DefaultConfig returns the recommended defaults.
func DefaultConfig() Config {
return Config{
Port: 9756,
Quality: 80,
FPS: 30,
Source: "screen",
Audio: true,
Port: 9756,
Quality: 80,
FPS: 30,
Source: "screen",
Audio: true,
Announce: true,
}
}
// Validate checks the config for out-of-range or unsupported values.
func (c Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return errors.New("port must be 165535")
}
if c.Quality < 1 || c.Quality > 100 {
return errors.New("quality must be 1100")
}
if c.FPS < 1 || c.FPS > 240 {
return errors.New("fps must be 1240")
}
if c.Source != "screen" && c.Source != "pattern" {
return errors.New("source must be screen or pattern")
}
if c.StreamIndex < 0 {
return errors.New("stream index must be >= 0")
}
return nil
}
// Status is a point-in-time snapshot of the running engine.
type Status struct {
Running bool
Frames int64
Dropped int64
Conns int
}
@@ -87,6 +112,10 @@ type Engine struct {
// New creates an engine from cfg. Capture is opened eagerly so that
// misconfiguration (e.g. no screen-share permission) surfaces before Start.
func New(cfg Config) (*Engine, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
e := &Engine{cfg: cfg}
sender := output.New()
@@ -124,8 +153,8 @@ func New(cfg Config) (*Engine, error) {
return e, nil
}
// Start begins the audio, video and stats loops and starts announcing the
// stream. It is idempotent.
// Start begins the audio, video and stats loops and (unless disabled in the
// config) starts announcing the stream. It is idempotent.
func (e *Engine) Start() {
if e.stop != nil {
return
@@ -134,7 +163,9 @@ func (e *Engine) Start() {
e.start = time.Now()
e.stop = make(chan struct{})
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
if e.cfg.Announce {
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
}
var src io.ReadCloser
switch {
@@ -180,6 +211,7 @@ func (e *Engine) Status() Status {
return Status{
Running: e.stop != nil,
Frames: e.frames.Load(),
Dropped: e.sender.Dropped(),
Conns: e.sender.NumConns(),
}
}
@@ -276,7 +308,11 @@ func (e *Engine) statsLoop() {
select {
case <-tick.C:
st := e.Status()
log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns)
if st.Dropped > 0 {
log.Printf("flinger: %d frames, %d dropped, %d conns", st.Frames, st.Dropped, st.Conns)
} else {
log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns)
}
case <-e.stop:
return
}
+24 -1
View File
@@ -11,7 +11,7 @@ import (
func TestEnginePatternStartStop(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "pattern"
cfg.Port = 0 // ephemeral
cfg.Port = 19756 // fixed high port for the test
eng, err := New(cfg)
if err != nil {
@@ -77,6 +77,29 @@ func TestNewRejectsBadSource(t *testing.T) {
}
}
// TestValidate rejects out-of-range values.
func TestValidate(t *testing.T) {
bad := []func(*Config){
func(c *Config) { c.Port = 0 },
func(c *Config) { c.Port = 70000 },
func(c *Config) { c.Quality = 0 },
func(c *Config) { c.Quality = 101 },
func(c *Config) { c.FPS = 0 },
func(c *Config) { c.StreamIndex = -1 },
}
for i, mutate := range bad {
c := DefaultConfig()
mutate(&c)
if err := c.Validate(); err == nil {
t.Errorf("case %d: expected validation error", i)
}
}
if err := DefaultConfig().Validate(); err != nil {
t.Errorf("default config should validate: %v", err)
}
}
func itoa(v int) string {
if v == 0 {
return "0"