impl connection recovery
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user