2026-08-19 20:50:03 +01:00
|
|
|
package httpserver
|
2026-04-08 13:59:41 +03:00
|
|
|
|
|
|
|
|
import (
|
2026-04-23 13:34:32 +03:00
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
2026-04-08 13:59:41 +03:00
|
|
|
"net/netip"
|
2026-04-23 13:34:32 +03:00
|
|
|
"sync"
|
2026-04-08 13:59:41 +03:00
|
|
|
"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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-28 14:21:39 +03:00
|
|
|
|
|
|
|
|
// 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())
|
|
|
|
|
}
|
2026-07-31 15:32:28 +03:00
|
|
|
|
|
|
|
|
// 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())
|
|
|
|
|
}
|