srs/internal/signal/signal_test.go
winlin 3060bf8e7c Claude: Split lb interfaces, extract redisclient, drop race-prone globals.
- Split OriginLoadBalancer into OriginService / HLSService / RTCService;
  the original interface now embeds the three role interfaces. Generate
  counterfeiter fakes for all four.
- Extract internal/redisclient: RedisClient interface + New() factory.
  internal/lb/redis.go no longer imports github.com/go-redis/redis/v8.
- Add unit tests for lb.go (OriginServer.ID/String/Format/NewOriginServer)
  and for the full memory + redis load balancers.
- Replace package-level test seams (memoryKeepaliveInterval, newRedisClient,
  redisKeepaliveInterval, signal.signalNotify/osExit, rtmp.createBuffer) with
  per-instance struct fields so concurrent tests can't race on them.
- Promote signal.InstallSignals / InstallForceQuit onto a new signal.Handler
  type; update bootstrap to construct one.
- Move rtmp createBuffer onto amf0ObjectBase as bufFactory; the three AMF0
  marshalers and their tests use the per-instance factory.
- Make proxy test scripts locate the workspace by walking up to go.mod
  instead of brittle '../../../..' counting (symlink-aware).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:18 -04:00

179 lines
4.0 KiB
Go

// Copyright (c) 2026 Winlin
//
// SPDX-License-Identifier: MIT
package signal
import (
"context"
"os"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"srsx/internal/env/envfakes"
)
// captureNotify returns a Handler whose notify field records the channel
// passed by the code under test, plus a getter that retrieves it.
func captureNotify() (*Handler, func() chan<- os.Signal) {
var (
mu sync.Mutex
ch chan<- os.Signal
)
h := &Handler{
notify: func(c chan<- os.Signal, _ ...os.Signal) {
mu.Lock()
defer mu.Unlock()
ch = c
},
exit: os.Exit,
}
return h, func() chan<- os.Signal {
mu.Lock()
defer mu.Unlock()
return ch
}
}
// captureExit returns a Handler whose exit field records the code and never
// returns, plus a flag and channel that observe the call.
func captureExit() (*Handler, *int32, chan int) {
var called int32
done := make(chan int, 1)
h := &Handler{
notify: func(chan<- os.Signal, ...os.Signal) {},
exit: func(code int) {
atomic.StoreInt32(&called, 1)
select {
case done <- code:
default:
}
// Block to mimic os.Exit never returning; the goroutine holding us
// here is abandoned when the test ends.
select {}
},
}
return h, &called, done
}
func TestInstallSignals_CancelsOnSignal(t *testing.T) {
h, getCh := captureNotify()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
h.InstallSignals(ctx, cancel)
ch := getCh()
if ch == nil {
t.Fatal("notify was not called")
}
ch <- syscall.SIGINT
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("ctx was not canceled after signal")
}
}
func TestInstallSignals_HandlesRepeatedSignals(t *testing.T) {
h, getCh := captureNotify()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
h.InstallSignals(ctx, cancel)
ch := getCh()
// Multiple signals must not panic; cancel() is idempotent.
ch <- syscall.SIGINT
ch <- syscall.SIGTERM
ch <- os.Interrupt
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("ctx was not canceled")
}
}
func TestInstallForceQuit_InvalidDurationReturnsError(t *testing.T) {
fakeEnv := &envfakes.FakeProxyEnvironment{}
fakeEnv.ForceQuitTimeoutReturns("not-a-duration")
err := NewHandler().InstallForceQuit(t.Context(), fakeEnv)
if err == nil {
t.Fatal("want error for bad duration")
}
if !strings.Contains(err.Error(), "parse force timeout") {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "not-a-duration") {
t.Fatalf("err missing input: %v", err)
}
}
func TestInstallForceQuit_ExitsAfterTimeout(t *testing.T) {
h, called, done := captureExit()
fakeEnv := &envfakes.FakeProxyEnvironment{}
fakeEnv.ForceQuitTimeoutReturns("1ms")
ctx, cancel := context.WithCancel(t.Context())
if err := h.InstallForceQuit(ctx, fakeEnv); err != nil {
t.Fatalf("unexpected err: %v", err)
}
// Before cancel, the goroutine is blocked and exit must not fire.
if atomic.LoadInt32(called) != 0 {
t.Fatal("exit called before ctx cancel")
}
cancel()
select {
case code := <-done:
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
case <-time.After(time.Second):
t.Fatal("exit not called after cancel + timeout")
}
}
func TestInstallForceQuit_WaitsForCancelBeforeSleeping(t *testing.T) {
h, called, done := captureExit()
fakeEnv := &envfakes.FakeProxyEnvironment{}
fakeEnv.ForceQuitTimeoutReturns("10ms")
// Intentionally use a never-canceled context and leak the goroutine: the
// handler's exit closure is owned by this test instance, so leaving the
// goroutine alive doesn't race other tests.
if err := h.InstallForceQuit(context.Background(), fakeEnv); err != nil {
t.Fatalf("unexpected err: %v", err)
}
select {
case <-done:
t.Fatal("exit fired without ctx cancel")
case <-time.After(30 * time.Millisecond):
}
if atomic.LoadInt32(called) != 0 {
t.Fatal("exit called unexpectedly")
}
}
func TestNewHandler_UsesRealOSDefaults(t *testing.T) {
h := NewHandler()
if h.notify == nil {
t.Error("notify default not set")
}
if h.exit == nil {
t.Error("exit default not set")
}
}