impl connection recovery

This commit is contained in:
iceBear67
2026-08-15 17:31:35 +08:00
parent e63a34d53a
commit 7bd84af48d
33 changed files with 3858 additions and 200 deletions
+147
View File
@@ -0,0 +1,147 @@
package e2e
import (
"context"
"fmt"
"io"
"net"
"testing"
"time"
"github.com/iceBear67/redapricot/client"
)
// startClientWithBandwidth is startClient with an egress cap, for the shaping
// tests. Only the client→hub direction is shaped, which in this harness is the
// leg carrying the mock destination's echo back to the player.
func startClientWithBandwidth(t *testing.T, hubAddr, psk string, maxConn int, bandwidth string, mappings []client.Mapping) *client.Client {
t.Helper()
cfg := &client.Config{
Server: hubAddr,
PSK: psk,
MaxConn: maxConn,
PingIntervalMs: 20000,
MaxBandwidth: bandwidth,
Mappings: mappings,
}
c := client.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
c.Close()
})
if err := c.Start(ctx); err != nil {
t.Fatalf("client start: %v", err)
}
time.Sleep(200 * time.Millisecond)
return c
}
// drain reads a connection until it fails, so a greedy player keeps pulling
// bytes instead of stalling on its own receive window.
func drain(conn net.Conn) {
go func() { _, _ = io.Copy(io.Discard, conn) }()
}
// TestBandwidthCapIsEnforced pushes a payload that cannot fit in the burst and
// asserts the transfer takes at least as long as the configured rate implies.
// This is the first timing assertion in the suite, so the bounds are wide: the
// unshaped path completes this in well under a second, making the lower bound
// an unambiguous signal rather than a tight measurement.
func TestBandwidthCapIsEnforced(t *testing.T) {
const psk = "e2e-bwcap"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClientWithBandwidth(t, hubAddr, psk, 1, "1MB/s", []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
payload := make([]byte, 2*1024*1024)
for i := range payload {
payload[i] = byte(i*31 + 7)
}
start := time.Now()
writeErr := make(chan error, 1)
go func() {
_, err := pc.Write(payload)
writeErr <- err
}()
got := make([]byte, len(payload))
_ = pc.SetReadDeadline(time.Now().Add(60 * time.Second))
if _, err := io.ReadFull(pc, got); err != nil {
t.Fatalf("read echo: %v", err)
}
elapsed := time.Since(start)
if err := <-writeErr; err != nil {
t.Fatalf("write payload: %v", err)
}
// 2 MiB at 1 MiB/s, minus the 200 KiB the bucket has banked, is ~1.8 s.
const floor = 1200 * time.Millisecond
if elapsed < floor {
t.Errorf("2 MiB echoed back in %v under a 1MB/s cap; expected at least %v, so the cap is not taking effect", elapsed, floor)
}
if elapsed > 30*time.Second {
t.Errorf("transfer took %v, far beyond the ~1.8s the rate implies", elapsed)
}
}
// TestCappedBandwidthDoesNotStarveLightStreams is the point of the fair queue:
// with the link deliberately capped and three players saturating it, a fourth
// player exchanging small messages must keep round-tripping promptly. A plain
// FIFO token bucket would leave it queued behind the heavy players' backlog,
// which for a real Minecraft client means a keepalive timeout — the exact
// failure this feature exists to prevent.
func TestCappedBandwidthDoesNotStarveLightStreams(t *testing.T) {
const psk = "e2e-bwfair"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
// maxConn=1 forces every stream onto one worker conn, so nothing but the
// shaper is deciding who gets the link.
startClientWithBandwidth(t, hubAddr, psk, 1, "1MB/s", []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
// Three greedy players: each writes megabytes and keeps draining the echo,
// so they compete for the cap for the whole test rather than parking on
// their own flow-control windows.
for i := 0; i < 3; i++ {
heavy := dialPlayer(t, hubAddr, "mc.local")
defer heavy.Close()
drain(heavy)
go func() { _, _ = heavy.Write(make([]byte, 8*1024*1024)) }()
}
time.Sleep(500 * time.Millisecond) // let them saturate the shaper
light := dialPlayer(t, hubAddr, "mc.local")
defer light.Close()
payload := make([]byte, 4*1024)
for i := range payload {
payload[i] = byte(i*13 + 5)
}
var worst time.Duration
for i := 0; i < 10; i++ {
start := time.Now()
playerEcho(t, light, payload)
if d := time.Since(start); d > worst {
worst = d
}
}
// Fair sharing puts a 4 KiB round at roughly 4 KiB / (1 MiB/s ÷ 4) ≈ 16 ms of
// link time. The bound is two orders of magnitude looser so only genuine
// starvation trips it.
const limit = 3 * time.Second
if worst > limit {
t.Errorf("slowest small round-trip took %v (limit %v); heavy streams are crowding out the light one", worst, limit)
}
}
+144
View File
@@ -0,0 +1,144 @@
package e2e
import (
"errors"
"fmt"
"io"
"net"
"testing"
"time"
"github.com/iceBear67/redapricot/client"
"github.com/iceBear67/redapricot/client/wire"
)
// A control session dying takes the client's routes with it. Players already
// tunneled are unaffected — they ride worker conns — but anyone *arriving*
// during the reconnect used to be told there is no such server, even though the
// tunnel was a second from being back.
//
// The hub now keeps those routes as orphaned for its registration grace and
// hangs arriving players on them, replaying the control request it never sent
// once a client re-registers the pattern.
// outageRelay starts a hub, an echoing destination, and a client reaching the
// hub only through a relay, so the tunnel can be cut without touching the
// players — who connect to the hub directly, as they would from the internet.
func outageRelay(t *testing.T, psk string, hubCfg map[string]any) (hubAddr string, relay *blackholeRelay) {
t.Helper()
hubPort := freePort(t)
hubAddr = fmt.Sprintf("127.0.0.1:%d", hubPort)
startHubCfg(t, hubPort, psk, hubCfg)
dest := newMockDest(t, modeEcho)
relay = newBlackholeRelay(t, hubAddr)
startClientWithPing(t, relay.addr, psk, 1, 400, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
return hubAddr, relay
}
// TestControlOutageHangsArrivingPlayer is the point of the feature: a player
// that shows up while the client is reconnecting gets held and then served,
// rather than refused.
func TestControlOutageHangsArrivingPlayer(t *testing.T) {
const psk = "e2e-ctl-hang"
hubAddr, relay := outageRelay(t, psk, nil)
// Cut the tunnel and keep it cut, so the client cannot re-register.
relay.stop()
relay.dropAll()
// Let the hub see the close and orphan the route before the player arrives.
time.Sleep(500 * time.Millisecond)
pc := resumePlayer(t, hubAddr, "mc.local")
if _, err := pc.Write([]byte("held")); err != nil {
t.Fatalf("player write during outage: %v", err)
}
// Nothing can come back yet — but the socket must still be open. Before this
// change the hub had already closed it.
_ = pc.SetReadDeadline(time.Now().Add(700 * time.Millisecond))
if _, err := pc.Read(make([]byte, 1)); err == nil {
t.Fatal("player was served while the route was orphaned")
} else if !isTimeout(err) {
t.Fatalf("player was dropped during the control outage instead of being held: %v", err)
}
relay.restore()
// The client reconnects, re-registers, and the hub replays the request it
// held — so the bytes written during the outage arrive at the destination and
// echo back on the same connection.
echo := make([]byte, 4)
_ = pc.SetReadDeadline(time.Now().Add(30 * time.Second))
if _, err := io.ReadFull(pc, echo); err != nil {
t.Fatalf("held player never served after the route came back: %v", err)
}
if string(echo) != "held" {
t.Fatalf("echo = %q, want %q", echo, "held")
}
}
// TestControlOutageGraceDisabledClosesPlayer pins the off switch: with the grace
// at zero the hub must drop the route the instant its session closes, exactly as
// it did before, rather than hanging players for a client that may never return.
func TestControlOutageGraceDisabledClosesPlayer(t *testing.T) {
const psk = "e2e-ctl-nohang"
hubAddr, relay := outageRelay(t, psk, map[string]any{"registrationGraceMs": 0})
relay.stop()
relay.dropAll()
time.Sleep(500 * time.Millisecond)
// No route at all now, so the hub closes the connection during the handshake.
pc, err := net.DialTimeout("tcp", hubAddr, 5*time.Second)
if err != nil {
t.Fatalf("player dial: %v", err)
}
defer pc.Close()
if _, err := pc.Write(playerHandshake("mc.local")); err != nil {
t.Fatalf("player handshake: %v", err)
}
_ = pc.SetReadDeadline(time.Now().Add(10 * time.Second))
if _, err := pc.Read(make([]byte, 1)); err == nil || isTimeout(err) {
t.Fatalf("player was held with the registration grace disabled (err=%v)", err)
}
}
// TestControlOutageHangExpiresClosesPlayer covers the other end: a route that is
// never reclaimed must not hold its players forever.
func TestControlOutageHangExpiresClosesPlayer(t *testing.T) {
const psk = "e2e-ctl-expire"
hubAddr, relay := outageRelay(t, psk, map[string]any{"registrationGraceMs": 2000})
relay.stop()
relay.dropAll()
time.Sleep(500 * time.Millisecond)
pc := resumePlayer(t, hubAddr, "mc.local")
start := time.Now()
_ = pc.SetReadDeadline(time.Now().Add(20 * time.Second))
if _, err := pc.Read(make([]byte, 1)); err == nil || isTimeout(err) {
t.Fatalf("held player was never released after the grace expired (err=%v)", err)
}
if elapsed := time.Since(start); elapsed < 500*time.Millisecond {
t.Fatalf("player closed after %s, so it was refused rather than held", elapsed)
} else {
t.Logf("held player released after %s", elapsed.Round(100*time.Millisecond))
}
}
// isTimeout distinguishes "still held, nothing to read yet" from "the hub closed
// us" — which is the whole distinction these tests turn on.
func isTimeout(err error) bool {
var ne net.Error
if errors.As(err, &ne) {
return ne.Timeout()
}
return false
}
func playerHandshake(host string) []byte {
return wire.BuildHandshake(767, host, 25565, 2)
}
+10 -3
View File
@@ -22,13 +22,19 @@ func startClient(t *testing.T, hubAddr, psk string, maxConn int, mappings []clie
// tests that need liveness detection to trigger quickly.
func startClientWithPing(t *testing.T, hubAddr, psk string, maxConn, pingMs int, mappings []client.Mapping) *client.Client {
t.Helper()
cfg := &client.Config{
return startClientCfg(t, &client.Config{
Server: hubAddr,
PSK: psk,
MaxConn: maxConn,
PingIntervalMs: pingMs,
Mappings: mappings,
}
})
}
// startClientCfg runs an in-process client from a fully-specified config, for
// tests that need a knob the shorthand helpers do not expose.
func startClientCfg(t *testing.T, cfg *client.Config) *client.Client {
t.Helper()
c := client.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
@@ -142,7 +148,8 @@ func TestLargeTransfer(t *testing.T) {
}
// TestConcurrentStreamsUseMultipleConns confirms the least-loaded allocator
// opens additional worker connections once streams saturate (>8).
// grows the pool breadth-first: concurrent streams spread over several worker
// connections rather than stacking on one, without ever exceeding maxConn.
func TestConcurrentStreamsUseMultipleConns(t *testing.T) {
const psk = "e2e-concurrent"
const n = 20
+22 -2
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
@@ -61,14 +62,33 @@ func freePort(t *testing.T) int {
// startHub launches the Java hub on the given port and blocks until it accepts
// connections. The process is killed on test cleanup.
func startHub(t *testing.T, port int, psk string) {
t.Helper()
startHubCfg(t, port, psk, nil)
}
// startHubCfg is startHub with extra config keys merged over the defaults, for
// tests that need to tune a hub-side knob.
func startHubCfg(t *testing.T, port int, psk string, extra map[string]any) {
t.Helper()
install := filepath.Join(repoRoot(), "server", "build", "install", "redapricot-server")
if _, err := os.Stat(install); err != nil {
t.Fatalf("hub not built at %s (run scripts/build.sh first): %v", install, err)
}
cfg := fmt.Sprintf(`{"listen":"127.0.0.1:%d","psk":%q,"timestampWindowMs":30000,"pendingTimeoutMs":5000}`, port, psk)
settings := map[string]any{
"listen": fmt.Sprintf("127.0.0.1:%d", port),
"psk": psk,
"timestampWindowMs": 30000,
"pendingTimeoutMs": 5000,
}
for k, v := range extra {
settings[k] = v
}
cfg, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
cfgPath := filepath.Join(t.TempDir(), "hub.json")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
if err := os.WriteFile(cfgPath, cfg, 0o644); err != nil {
t.Fatal(err)
}
+33
View File
@@ -26,6 +26,7 @@ type blackholeRelay struct {
addr string
backend string
gen atomic.Uint64
down atomic.Bool
mu sync.Mutex
held []net.Conn
}
@@ -59,6 +60,10 @@ func newBlackholeRelay(t *testing.T, backend string) *blackholeRelay {
func (r *blackholeRelay) handle(cli net.Conn) {
born := r.gen.Load()
if r.down.Load() {
_ = cli.Close()
return
}
up, err := net.Dial("tcp", r.backend)
if err != nil {
_ = cli.Close()
@@ -89,6 +94,34 @@ func (r *blackholeRelay) handle(cli net.Conn) {
// unaffected.
func (r *blackholeRelay) blackhole() { r.gen.Add(1) }
// dropAll hard-resets every currently-established pair: a real FIN/RST reaches
// both ends immediately, as when a middlebox is restarted or a route flaps,
// rather than the silent stranding blackhole models. Later connections are
// carried normally.
//
// stop takes the relay out of service: new connections are refused rather than
// carried, so the tunnel stays down until restore is called. Models an outage
// the client cannot immediately reconnect through.
func (r *blackholeRelay) stop() { r.down.Store(true) }
// restore puts the relay back in service. Flows stranded before it are still
// dead — only fresh connections are carried, which is what a client gets after
// a middlebox or upstream link comes back.
func (r *blackholeRelay) restore() { r.down.Store(false) }
// This is the fast path into stream resumption: the client learns the conn is
// gone at once instead of waiting out a heartbeat timeout.
func (r *blackholeRelay) dropAll() {
r.gen.Add(1)
r.mu.Lock()
held := r.held
r.held = nil
r.mu.Unlock()
for _, c := range held {
_ = c.Close()
}
}
// TestBlackholedPathRecovers is the end-to-end regression guard for the
// stability bug this hardening was written for: with the tunnel's path silently
// dropped, the client used to notice nothing at all. Its read loops parked
+270
View File
@@ -0,0 +1,270 @@
package e2e
import (
"bytes"
"fmt"
"io"
"math/rand"
"net"
"sync"
"testing"
"time"
"github.com/iceBear67/redapricot/client"
"github.com/iceBear67/redapricot/client/wire"
)
// resumePlayer opens a player connection and returns it, having sent only the
// handshake. The caller keeps it open across the outage — which is the whole
// point: before stream resumption this socket was closed by the hub the instant
// its worker conn died.
func resumePlayer(t *testing.T, hubAddr, host string) net.Conn {
t.Helper()
pc, err := net.DialTimeout("tcp", hubAddr, 5*time.Second)
if err != nil {
t.Fatalf("player dial: %v", err)
}
t.Cleanup(func() { _ = pc.Close() })
if _, err := pc.Write(wire.BuildHandshake(767, host, 25565, 2)); err != nil {
t.Fatalf("player handshake: %v", err)
}
return pc
}
// echoExchange streams payload through an echoing destination and verifies that
// what comes back is byte-for-byte identical, calling disrupt once `at` bytes
// have made the round trip.
//
// Comparing the whole stream rather than sampling is deliberate: a resumption
// bug does not corrupt bytes, it duplicates or skips a range, and only an exact
// comparison of the full sequence catches an off-by-one in the offsets.
func echoExchange(t *testing.T, pc net.Conn, payload []byte, at int, disrupt func()) {
t.Helper()
const chunk = 16 << 10
var wg sync.WaitGroup
wg.Add(1)
writeErr := make(chan error, 1)
go func() {
defer wg.Done()
for off := 0; off < len(payload); off += chunk {
end := min(off+chunk, len(payload))
_ = pc.SetWriteDeadline(time.Now().Add(60 * time.Second))
if _, err := pc.Write(payload[off:end]); err != nil {
writeErr <- fmt.Errorf("write at %d: %w", off, err)
return
}
}
writeErr <- nil
}()
got := make([]byte, len(payload))
read, fired := 0, false
for read < len(got) {
_ = pc.SetReadDeadline(time.Now().Add(60 * time.Second))
n, err := pc.Read(got[read:])
read += n
if !fired && read >= at {
fired = true
disrupt()
}
if err != nil {
t.Fatalf("player read failed after %d/%d bytes: %v", read, len(got), err)
}
}
wg.Wait()
if err := <-writeErr; err != nil {
t.Fatalf("player write: %v", err)
}
if !bytes.Equal(got, payload) {
// Report the first divergence: its offset says whether the stream gained
// or lost bytes, which is the difference between a retransmit that
// replayed too much and one that replayed too little.
for i := range got {
if got[i] != payload[i] {
t.Fatalf("echo diverges at byte %d of %d (sent %#x, got %#x)",
i, len(payload), payload[i], got[i])
}
}
}
}
func randomPayload(seed int64, n int) []byte {
p := make([]byte, n)
rand.New(rand.NewSource(seed)).Read(p)
return p
}
// TestResumePreservesByteStream is the correctness bar for stream resumption:
// a worker conn is hard-reset mid-transfer and the *same* player connection must
// keep working, with a byte stream that neither gains nor loses a single byte.
//
// Byte-exactness is the whole difficulty. Frames handed to a dying socket are
// lost with no notification and the cipher cannot be resynchronized, so each
// side has to replay from the offset the other reports it accepted. Getting that
// offset wrong by any amount splices the stream mid-Minecraft-packet, which a
// round-trip test that only checked "traffic flows again" would happily pass.
func TestResumePreservesByteStream(t *testing.T) {
const psk = "e2e-resume"
hubPort := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", hubPort)
startHub(t, hubPort, psk)
dest := newMockDest(t, modeEcho)
// Only the tunnel runs through the relay; the player talks to the hub
// directly, as it would from the internet. So the drop hits the middle leg
// while both terminal sockets stay healthy — exactly the case resumption is
// for.
relay := newBlackholeRelay(t, hubAddr)
c := startClientWithPing(t, relay.addr, psk, 1, 400, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := resumePlayer(t, hubAddr, "mc.local")
payload := randomPayload(1, 3<<20)
echoExchange(t, pc, payload, 512<<10, func() {
t.Log("hard-resetting the tunnel mid-transfer")
relay.dropAll()
})
t.Logf("stream survived the reset intact; worker conns now %d", c.WorkerConnCount())
}
// TestResumeWithConcurrentStreams covers the failure the single-stream test
// cannot reach: stream ids restart at 1 on every conn, so after a reattach two
// players can hold the same id on different conns. A binding that updates the
// conn and the id separately will credit or reset the wrong player's stream, and
// that only shows up when a second stream is there to be corrupted.
func TestResumeWithConcurrentStreams(t *testing.T) {
const psk = "e2e-resume-multi"
hubPort := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", hubPort)
startHub(t, hubPort, psk)
dest := newMockDest(t, modeEcho)
relay := newBlackholeRelay(t, hubAddr)
startClientWithPing(t, relay.addr, psk, 2, 400, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
const players = 3
conns := make([]net.Conn, players)
for i := range conns {
conns[i] = resumePlayer(t, hubAddr, "mc.local")
// Distinct payloads: if a reattach crosses two streams the bytes land on
// the wrong player, which an identical payload would hide.
if _, err := conns[i].Write([]byte(fmt.Sprintf("hello-%d", i))); err != nil {
t.Fatalf("player %d warmup write: %v", i, err)
}
echo := make([]byte, len("hello-0"))
_ = conns[i].SetReadDeadline(time.Now().Add(15 * time.Second))
if _, err := io.ReadFull(conns[i], echo); err != nil {
t.Fatalf("player %d warmup echo: %v", i, err)
}
}
var wg sync.WaitGroup
for i := range conns {
wg.Add(1)
go func(i int) {
defer wg.Done()
payload := randomPayload(int64(100+i), 768<<10)
// Only the first player triggers the reset; the others are mid-flight
// when it lands.
disrupt := func() {}
if i == 0 {
disrupt = relay.dropAll
}
echoExchange(t, conns[i], payload, 128<<10, disrupt)
}(i)
}
wg.Wait()
}
// TestResumeDisabledClosesImmediately pins the off switch. With resumption
// declined the hub must not hang the player waiting for a reattach that is never
// coming: the socket has to close as it did before the feature existed, so
// disabling it is a true revert rather than a slower failure.
func TestResumeDisabledClosesImmediately(t *testing.T) {
const psk = "e2e-resume-off"
hubPort := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", hubPort)
startHub(t, hubPort, psk)
dest := newMockDest(t, modeEcho)
relay := newBlackholeRelay(t, hubAddr)
off := false
startClientCfg(t, &client.Config{
Server: relay.addr,
PSK: psk,
MaxConn: 1,
PingIntervalMs: 400,
StreamResume: &off,
Mappings: []client.Mapping{{Pattern: "mc.local", Destination: dest.addr}},
})
pc := resumePlayer(t, hubAddr, "mc.local")
if _, err := pc.Write([]byte("ping")); err != nil {
t.Fatalf("warmup write: %v", err)
}
echo := make([]byte, 4)
_ = pc.SetReadDeadline(time.Now().Add(15 * time.Second))
if _, err := io.ReadFull(pc, echo); err != nil {
t.Fatalf("warmup echo: %v", err)
}
relay.dropAll()
// Well inside the hub's 20s grace: if the player is still open here, the hub
// parked a stream for a client that never opted in.
_ = pc.SetReadDeadline(time.Now().Add(10 * time.Second))
if _, err := pc.Read(make([]byte, 1)); err == nil {
t.Fatal("player socket stayed open after the tunnel dropped with resume disabled")
}
}
// TestResumeGraceExpiryClosesPlayer covers the other end of the lifetime: when
// the tunnel never comes back, a hung player must not hang forever. The hub
// drops it once its grace expires, without leaking the stream or its buffers.
func TestResumeGraceExpiryClosesPlayer(t *testing.T) {
const psk = "e2e-resume-grace"
hubPort := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", hubPort)
startHubCfg(t, hubPort, psk, map[string]any{"resumeGraceMs": 3000})
dest := newMockDest(t, modeEcho)
relay := newBlackholeRelay(t, hubAddr)
startClientCfg(t, &client.Config{
Server: relay.addr,
PSK: psk,
MaxConn: 1,
PingIntervalMs: 400,
ResumeGraceMs: 2000,
Mappings: []client.Mapping{{Pattern: "mc.local", Destination: dest.addr}},
})
pc := resumePlayer(t, hubAddr, "mc.local")
if _, err := pc.Write([]byte("ping")); err != nil {
t.Fatalf("warmup write: %v", err)
}
echo := make([]byte, 4)
_ = pc.SetReadDeadline(time.Now().Add(15 * time.Second))
if _, err := io.ReadFull(pc, echo); err != nil {
t.Fatalf("warmup echo: %v", err)
}
// Stop carrying the tunnel entirely, so every reattach attempt fails.
relay.stop()
relay.dropAll()
start := time.Now()
_ = pc.SetReadDeadline(time.Now().Add(30 * time.Second))
if _, err := pc.Read(make([]byte, 1)); err == nil {
t.Fatal("player socket never closed after the resume grace expired")
}
if elapsed := time.Since(start); elapsed < time.Second {
t.Fatalf("player closed after %s, before any reattach could be attempted", elapsed)
} else {
t.Logf("hung player released after %s", elapsed.Round(100*time.Millisecond))
}
}