|
|
|
@@ -5,7 +5,13 @@ import ( |
|
|
|
"context" |
|
|
|
"errors" |
|
|
|
"io" |
|
|
|
"net/http" |
|
|
|
"net/http/httptest" |
|
|
|
"strings" |
|
|
|
"sync" |
|
|
|
"sync/atomic" |
|
|
|
"testing" |
|
|
|
"time" |
|
|
|
|
|
|
|
"github.com/jan/fm-rds-tx/internal/ingest" |
|
|
|
"github.com/jan/fm-rds-tx/internal/ingest/decoder" |
|
|
|
@@ -304,3 +310,149 @@ func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { |
|
|
|
t.Fatalf("codec=%s want ffmpeg", got) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
type scriptedLoopDecoder struct { |
|
|
|
mu sync.Mutex |
|
|
|
actions []decodeAction |
|
|
|
calls int |
|
|
|
totalBytesRead int |
|
|
|
} |
|
|
|
|
|
|
|
type decodeAction struct { |
|
|
|
err error |
|
|
|
blockUntilStop bool |
|
|
|
} |
|
|
|
|
|
|
|
func (d *scriptedLoopDecoder) Name() string { return "scripted-loop" } |
|
|
|
|
|
|
|
func (d *scriptedLoopDecoder) DecodeStream(ctx context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { |
|
|
|
data, err := io.ReadAll(r) |
|
|
|
if err != nil { |
|
|
|
return err |
|
|
|
} |
|
|
|
|
|
|
|
d.mu.Lock() |
|
|
|
d.calls++ |
|
|
|
d.totalBytesRead += len(data) |
|
|
|
callIdx := d.calls - 1 |
|
|
|
action := decodeAction{} |
|
|
|
if callIdx < len(d.actions) { |
|
|
|
action = d.actions[callIdx] |
|
|
|
} |
|
|
|
d.mu.Unlock() |
|
|
|
|
|
|
|
if action.blockUntilStop { |
|
|
|
<-ctx.Done() |
|
|
|
return nil |
|
|
|
} |
|
|
|
return action.err |
|
|
|
} |
|
|
|
|
|
|
|
func (d *scriptedLoopDecoder) callCount() int { |
|
|
|
d.mu.Lock() |
|
|
|
defer d.mu.Unlock() |
|
|
|
return d.calls |
|
|
|
} |
|
|
|
|
|
|
|
func TestSourceReconnectsWhenStreamEndsCleanly(t *testing.T) { |
|
|
|
var requests atomic.Int64 |
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
|
|
|
requests.Add(1) |
|
|
|
w.Header().Set("Content-Type", "audio/mpeg") |
|
|
|
_, _ = w.Write([]byte("test-stream")) |
|
|
|
})) |
|
|
|
defer srv.Close() |
|
|
|
|
|
|
|
dec := &scriptedLoopDecoder{ |
|
|
|
actions: []decodeAction{ |
|
|
|
{}, // first connection ends cleanly (EOS-like) |
|
|
|
{blockUntilStop: true}, |
|
|
|
}, |
|
|
|
} |
|
|
|
reg := decoder.NewRegistry() |
|
|
|
reg.Register("mp3", func() decoder.Decoder { return dec }) |
|
|
|
reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} }) |
|
|
|
|
|
|
|
src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{ |
|
|
|
Enabled: true, |
|
|
|
InitialBackoffMs: 1, |
|
|
|
MaxBackoffMs: 1, |
|
|
|
}, WithDecoderRegistry(reg), WithDecoderPreference("auto")) |
|
|
|
|
|
|
|
if err := src.Start(context.Background()); err != nil { |
|
|
|
t.Fatalf("start: %v", err) |
|
|
|
} |
|
|
|
defer src.Stop() |
|
|
|
|
|
|
|
waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after clean EOS") |
|
|
|
|
|
|
|
stats := src.Stats() |
|
|
|
if stats.Reconnects < 1 { |
|
|
|
t.Fatalf("reconnects=%d want >=1", stats.Reconnects) |
|
|
|
} |
|
|
|
if got := requests.Load(); got < 2 { |
|
|
|
t.Fatalf("requests=%d want >=2", got) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) { |
|
|
|
const boom = "decoder boom" |
|
|
|
var requests atomic.Int64 |
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
|
|
|
requests.Add(1) |
|
|
|
w.Header().Set("Content-Type", "audio/mpeg") |
|
|
|
_, _ = w.Write([]byte("test-stream")) |
|
|
|
})) |
|
|
|
defer srv.Close() |
|
|
|
|
|
|
|
dec := &scriptedLoopDecoder{ |
|
|
|
actions: []decodeAction{ |
|
|
|
{err: errors.New(boom)}, // first attempt fails |
|
|
|
{blockUntilStop: true}, // second attempt recovers and stays running |
|
|
|
}, |
|
|
|
} |
|
|
|
reg := decoder.NewRegistry() |
|
|
|
reg.Register("mp3", func() decoder.Decoder { return dec }) |
|
|
|
reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} }) |
|
|
|
|
|
|
|
src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{ |
|
|
|
Enabled: true, |
|
|
|
InitialBackoffMs: 1, |
|
|
|
MaxBackoffMs: 1, |
|
|
|
}, WithDecoderRegistry(reg), WithDecoderPreference("auto")) |
|
|
|
|
|
|
|
if err := src.Start(context.Background()); err != nil { |
|
|
|
t.Fatalf("start: %v", err) |
|
|
|
} |
|
|
|
defer src.Stop() |
|
|
|
|
|
|
|
select { |
|
|
|
case err := <-src.Errors(): |
|
|
|
if err == nil || !strings.Contains(err.Error(), boom) { |
|
|
|
t.Fatalf("error=%v want contains %q", err, boom) |
|
|
|
} |
|
|
|
case <-time.After(1 * time.Second): |
|
|
|
t.Fatal("timed out waiting for source error reporting") |
|
|
|
} |
|
|
|
|
|
|
|
waitForCondition(t, func() bool { |
|
|
|
st := src.Stats() |
|
|
|
return dec.callCount() >= 2 && st.LastError == "" |
|
|
|
}, "lastError cleared after successful reconnect") |
|
|
|
|
|
|
|
if got := requests.Load(); got < 2 { |
|
|
|
t.Fatalf("requests=%d want >=2", got) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func waitForCondition(t *testing.T, cond func() bool, label string) { |
|
|
|
t.Helper() |
|
|
|
deadline := time.Now().Add(2 * time.Second) |
|
|
|
for time.Now().Before(deadline) { |
|
|
|
if cond() { |
|
|
|
return |
|
|
|
} |
|
|
|
time.Sleep(10 * time.Millisecond) |
|
|
|
} |
|
|
|
t.Fatalf("timeout waiting for condition: %s", label) |
|
|
|
} |