Files

216 lines
5.7 KiB
Go
Raw Permalink Normal View History

2026-08-19 20:50:03 +01:00
package httpserver
import (
2026-04-23 13:34:32 +03:00
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
2026-04-23 13:34:32 +03:00
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestGetBindAddrPortUsesCachedPortForEphemeralConfig(t *testing.T) {
s := NewServer(zap.NewNop(), &Config{
AddrPort: netip.MustParseAddrPort("127.0.0.1:0"),
})
s.cachedPort = 45678
require.Equal(t, netip.MustParseAddrPort("127.0.0.1:45678"), s.getBindAddrPort())
}
func TestServerToBackgroundToForegroundReusesEphemeralPort(t *testing.T) {
s := NewServer(zap.NewNop(), &Config{
AddrPort: netip.MustParseAddrPort("127.0.0.1:0"),
})
require.NoError(t, s.Start())
require.Eventually(t, func() bool {
return s.GetPort() != 0
}, time.Second, 10*time.Millisecond)
firstPort := s.GetPort()
require.NotZero(t, firstPort)
s.ToBackground()
require.Eventually(t, func() bool {
return !s.IsRunning()
}, time.Second, 10*time.Millisecond)
s.ToForeground()
require.Eventually(t, func() bool {
return s.IsRunning() && s.GetPort() != 0
}, time.Second, 10*time.Millisecond)
require.Equal(t, firstPort, s.GetPort())
require.NoError(t, s.Stop())
}
2026-04-23 13:34:32 +03:00
func TestShouldForceCloseOnShutdownError(t *testing.T) {
require.False(t, shouldForceCloseOnShutdownError(nil))
require.True(t, shouldForceCloseOnShutdownError(context.DeadlineExceeded))
require.True(t, shouldForceCloseOnShutdownError(context.Canceled))
require.True(t, shouldForceCloseOnShutdownError(fmt.Errorf("wrapped: %w", context.DeadlineExceeded)))
require.False(t, shouldForceCloseOnShutdownError(errors.New("boom")))
}
func TestServerToBackgroundReturnsPromptlyAndStopsAcceptingConnections(t *testing.T) {
s := NewServer(zap.NewNop(), &Config{
AddrPort: netip.MustParseAddrPort("127.0.0.1:0"),
})
started := make(chan struct{})
release := make(chan struct{})
var startedOnce sync.Once
var releaseOnce sync.Once
s.SetHandlers(HandlerPatternMap{
"/block": func(w http.ResponseWriter, r *http.Request) {
startedOnce.Do(func() { close(started) })
select {
case <-release:
case <-r.Context().Done():
}
_, _ = io.WriteString(w, "ok")
},
})
require.NoError(t, s.Start())
t.Cleanup(func() {
releaseOnce.Do(func() { close(release) })
_ = s.Stop()
})
require.Eventually(t, func() bool {
return s.GetListeningAddrPort() != ""
}, time.Second, 10*time.Millisecond)
addr := s.GetListeningAddrPort()
reqErr := make(chan error, 1)
go func() {
resp, err := http.Get("http://" + addr + "/block")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
reqErr <- err
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("timed out waiting for blocking request to start")
}
start := time.Now()
s.ToBackground()
elapsed := time.Since(start)
require.LessOrEqual(t, elapsed, backgroundShutdownTimeout+250*time.Millisecond)
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
if err == nil {
_ = conn.Close()
}
require.Error(t, err)
releaseOnce.Do(func() { close(release) })
select {
case <-reqErr:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for blocked request to finish")
}
}
// Simulates the iOS suspension failure: the OS kills the listening socket
// without the accept loop returning (running stays true), so a later Start()
// must detect the dead listener and rebind instead of early-returning.
func TestStartRebindsWhenListenerDiesSilently(t *testing.T) {
s := NewServer(zap.NewNop(), &Config{
AddrPort: netip.MustParseAddrPort("127.0.0.1:0"),
})
require.NoError(t, s.Start())
require.Eventually(t, func() bool {
return s.GetPort() != 0 && s.listenerAlive()
}, time.Second, 10*time.Millisecond)
firstPort := s.GetPort()
// Kill the listener out from under the server; Serve returns an error and
// the serve goroutine exits, but on iOS running can remain true — force
// that state to model it.
require.NoError(t, s.listener.Close())
s.serveWg.Wait()
s.running.Store(true)
require.False(t, s.listenerAlive())
require.NoError(t, s.Start())
require.Eventually(t, func() bool {
return s.IsRunning() && s.listenerAlive()
}, time.Second, 10*time.Millisecond)
require.Equal(t, firstPort, s.GetPort())
require.NoError(t, s.Stop())
}
// Regression: a handler that never returns (e.g. the media server blocked on a
// stuck IPFS download) used to wedge Stop forever, and with it the whole logout
// path that owns the backend mutex.
func TestServerStopReturnsDespiteStuckHandler(t *testing.T) {
s := NewServer(zap.NewNop(), &Config{
AddrPort: netip.MustParseAddrPort("127.0.0.1:0"),
})
started := make(chan struct{})
release := make(chan struct{})
var startedOnce sync.Once
var releaseOnce sync.Once
s.SetHandlers(HandlerPatternMap{
// Deliberately ignores r.Context(): Shutdown does not cancel in-flight
// requests, so only a bounded Stop can break this.
"/stuck": func(w http.ResponseWriter, r *http.Request) {
startedOnce.Do(func() { close(started) })
<-release
},
})
require.NoError(t, s.Start())
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
require.Eventually(t, func() bool {
return s.GetListeningAddrPort() != ""
}, time.Second, 10*time.Millisecond)
go func() {
resp, err := http.Get("http://" + s.GetListeningAddrPort() + "/stuck")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("timed out waiting for stuck request to start")
}
stopped := make(chan error, 1)
go func() { stopped <- s.Stop() }()
select {
case <-stopped:
case <-time.After(teardownShutdownTimeout + 2*time.Second):
t.Fatal("Stop did not return while a handler was stuck")
}
require.False(t, s.IsRunning())
}