aboutsummaryrefslogtreecommitdiff
path: root/internal/net/server_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/net/server_test.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/net/server_test.go')
-rw-r--r--internal/net/server_test.go214
1 files changed, 214 insertions, 0 deletions
diff --git a/internal/net/server_test.go b/internal/net/server_test.go
index c91c701..da8084a 100644
--- a/internal/net/server_test.go
+++ b/internal/net/server_test.go
@@ -1,6 +1,11 @@
package net
import (
+ "bytes"
+ "errors"
+ "io"
+ "net"
+ "strings"
"sync"
"testing"
"time"
@@ -46,6 +51,215 @@ func (c *testConn) SetWriteDeadline(time.Time) error { return nil }
func (c *testConn) Close() error { return nil }
func (c *testConn) SetEcho(bool) error { return nil }
+// fakeNetConn is a net.Conn stub for the session read loop: Read reports the
+// peer hung up so the read loop ends immediately, Write captures what the
+// server sent, and read deadlines are recorded so a test can assert the
+// transport was armed.
+type fakeNetConn struct {
+ mu sync.Mutex
+ written bytes.Buffer
+ deadline time.Time
+}
+
+func newFakeNetConn() *fakeNetConn { return &fakeNetConn{} }
+
+func (c *fakeNetConn) Read([]byte) (int, error) { return 0, io.EOF }
+
+func (c *fakeNetConn) Write(b []byte) (int, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.written.Write(b)
+}
+
+func (c *fakeNetConn) Close() error { return nil }
+
+func (c *fakeNetConn) LocalAddr() net.Addr { return nil }
+func (c *fakeNetConn) RemoteAddr() net.Addr { return nil }
+
+func (c *fakeNetConn) SetDeadline(t time.Time) error { return c.SetReadDeadline(t) }
+
+func (c *fakeNetConn) SetReadDeadline(t time.Time) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.deadline = t
+ return nil
+}
+
+func (c *fakeNetConn) SetWriteDeadline(time.Time) error { return nil }
+
+func (c *fakeNetConn) output() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.written.String()
+}
+
+func (c *fakeNetConn) readDeadline() time.Time {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.deadline
+}
+
+// TestConnectionCapRefusalKeepsTheServerDrained: at maxConnections a new
+// connection is refused without holding a slot (the counter stays at the cap),
+// and the refused peer is told why. Once the admitted sessions end, the live
+// count is back to zero.
+func TestConnectionCapRefusalKeepsTheServerDrained(t *testing.T) {
+ s := &Server{hub: NewHub()}
+ s.handler = func(*Session, string) {}
+
+ for i := 0; i < maxConnections; i++ {
+ if !s.admitConnection() {
+ t.Fatalf("connection %d of %d refused below the cap", i+1, maxConnections)
+ }
+ }
+
+ refused := newFakeNetConn()
+ s.handleSession(&Session{Conn: newTCPConn(refused)}, s.handler)
+
+ if got := s.conns.Load(); got != maxConnections {
+ t.Fatalf("live connection count after a refusal = %d, want %d (the refused connection held a slot)", got, maxConnections)
+ }
+ if !strings.Contains(refused.output(), "full capacity") {
+ t.Fatalf("refused connection was not told the server is full: %q", refused.output())
+ }
+
+ for i := 0; i < maxConnections; i++ {
+ s.releaseConnection()
+ }
+ if got := s.conns.Load(); got != 0 {
+ t.Fatalf("live connection count after every session ended = %d, want 0", got)
+ }
+}
+
+// TestAdmittedSessionReleasesItsSlot: an admitted session whose client hangs
+// up returns its slot, so a client cannot leak capacity by disconnecting.
+func TestAdmittedSessionReleasesItsSlot(t *testing.T) {
+ s := &Server{hub: NewHub()}
+ s.handler = func(*Session, string) {}
+
+ s.handleSession(&Session{Conn: newTCPConn(newFakeNetConn())}, s.handler)
+
+ if got := s.conns.Load(); got != 0 {
+ t.Fatalf("live connection count after the session ended = %d, want 0", got)
+ }
+}
+
+// TestArmReadDeadlineArmsTheTransport: the idle window is applied to the real
+// transport (not just computed), and the window itself depends on the protocol
+// state: pre-auth prompts get the short one.
+func TestArmReadDeadlineArmsTheTransport(t *testing.T) {
+ rec := newFakeNetConn()
+ sess := &Session{Conn: newTCPConn(rec)}
+
+ before := time.Now()
+ armReadDeadline(sess, loginReadTimeout)
+ got := rec.readDeadline()
+ if got.Before(before.Add(loginReadTimeout)) || got.After(time.Now().Add(loginReadTimeout)) {
+ t.Fatalf("armed read deadline = %v, want about %v", got, before.Add(loginReadTimeout))
+ }
+
+ sess.SetState(StateAccountName)
+ if got := sessionIdleTimeout(sess); got != loginReadTimeout {
+ t.Fatalf("account-name window = %v, want %v", got, loginReadTimeout)
+ }
+ sess.SetState(StatePassword)
+ if got := sessionIdleTimeout(sess); got != loginReadTimeout {
+ t.Fatalf("password window = %v, want %v", got, loginReadTimeout)
+ }
+ sess.SetState(StateGame)
+ if got := sessionIdleTimeout(sess); got != idleReadTimeout {
+ t.Fatalf("in-game window = %v, want %v", got, idleReadTimeout)
+ }
+}
+
+// TestIdleSessionDroppedWithoutAffectingActiveOne: an idle socket is dropped
+// once its armed read deadline expires, while a client that keeps sending
+// messages survives well past that window because every read re-arms the
+// deadline.
+func TestIdleSessionDroppedWithoutAffectingActiveOne(t *testing.T) {
+ s := &Server{hub: NewHub()}
+ var mu sync.Mutex
+ served := 0
+ s.handler = func(*Session, string) {
+ mu.Lock()
+ served++
+ mu.Unlock()
+ }
+ const window = 200 * time.Millisecond
+ s.idleTimeout = func(*Session) time.Duration { return window }
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ln.Close()
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ sess := &Session{Conn: newTCPConn(conn)}
+ sess.SetState(StateAccountName)
+ s.hub.Add(sess)
+ go s.handleSession(sess, s.handler)
+ }
+ }()
+
+ idle, err := net.Dial("tcp", ln.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer idle.Close()
+ active, err := net.Dial("tcp", ln.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer active.Close()
+
+ // The active client talks for five windows' worth of messages: it can only
+ // be served to the end if each message re-armed its read deadline.
+ sent := 10
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for i := 0; i < sent; i++ {
+ if _, err := active.Write([]byte("look\r\n")); err != nil {
+ return
+ }
+ time.Sleep(window / 4)
+ }
+ }()
+
+ // The silent client is dropped: its socket is closed by the server as soon
+ // as the armed deadline expires.
+ if err := idle.SetReadDeadline(time.Now().Add(10 * window)); err != nil {
+ t.Fatal(err)
+ }
+ buf := make([]byte, 4096)
+ for {
+ n, err := idle.Read(buf)
+ if n > 0 {
+ continue
+ }
+ if err == nil {
+ continue
+ }
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ t.Fatalf("idle client was not dropped after its read deadline: %v", err)
+ }
+
+ <-done
+ mu.Lock()
+ got := served
+ mu.Unlock()
+ if got < sent-2 {
+ t.Fatalf("the active client was served %d of %d messages; its read deadline was not re-armed per message", got, sent)
+ }
+}
+
func TestSessionWriteConcurrency(t *testing.T) {
conn := &testConn{}
sess := &Session{Conn: conn}