aboutsummaryrefslogtreecommitdiff
path: root/internal/net
diff options
context:
space:
mode:
Diffstat (limited to 'internal/net')
-rw-r--r--internal/net/server.go13
-rw-r--r--internal/net/session_writer_test.go67
2 files changed, 80 insertions, 0 deletions
diff --git a/internal/net/server.go b/internal/net/server.go
index b0407fc..42077a1 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -174,7 +174,15 @@ func (sess *Session) StartWriter() {
// enqueue appends msg to the outbound queue. When the client's queue is full
// the session is disconnected instead of blocking the caller: the world must
// never wait on a single slow client.
+// enqueue appends msg to the outbound queue. When the client's queue is
+// full the session is disconnected instead of blocking the caller: the world
+// must never wait on a single slow client.
func (sess *Session) enqueue(msg string) {
+ // A broadcast can race Close(): the queue may already be (or become)
+ // closed mid-send. Such late writes are dropped, never panicked on —
+ // Close() closes the queue so the writer goroutine drains whatever was
+ // queued before the close and then flushes the transport.
+ defer func() { _ = recover() }()
select {
case sess.outbox <- msg:
default:
@@ -490,6 +498,10 @@ func (s *Server) serveTelnet() {
Conn: newTCPConn(conn),
State: StateAccountName,
}
+ // Queued writes like the websocket transport: a dedicated writer
+ // goroutine performs the I/O with per-message deadlines, so a slow
+ // or stalled telnet client can never block game code.
+ sess.StartWriter()
s.hub.Add(sess)
go s.handleSession(sess, s.handler)
}
@@ -505,6 +517,7 @@ func (s *Server) serveTelnetTLS() {
Conn: newTCPConn(conn),
State: StateAccountName,
}
+ sess.StartWriter()
s.hub.Add(sess)
go s.handleSession(sess, s.handler)
}
diff --git a/internal/net/session_writer_test.go b/internal/net/session_writer_test.go
new file mode 100644
index 0000000..94de4ba
--- /dev/null
+++ b/internal/net/session_writer_test.go
@@ -0,0 +1,67 @@
+package net
+
+import (
+ stdnet "net"
+ "testing"
+ "time"
+
+ "thehouseoficarus/internal/config"
+)
+
+// TestEnqueueAfterCloseDoesNotPanic: a broadcast racing Close() must drop
+// the message, never panic on a send to the closed outbound queue.
+func TestEnqueueAfterCloseDoesNotPanic(t *testing.T) {
+ conn := &testConn{}
+ sess := &Session{Conn: conn}
+ sess.StartWriter()
+ sess.Close()
+
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("write after Close panicked: %v", r)
+ }
+ // Wait for the writer goroutine to drain and exit cleanly.
+ select {
+ case <-sess.writeDone:
+ case <-time.After(time.Second):
+ t.Fatal("writer goroutine never exited after close")
+ }
+ }()
+ for i := 0; i < 3; i++ {
+ sess.WriteLine("late broadcast")
+ }
+}
+
+// TestTelnetSessionUsesQueuedWriter: production telnet sessions must start
+// the queued writer like the websocket transport, so slow clients get
+// per-message write deadlines and can never block game code.
+func TestTelnetSessionUsesQueuedWriter(t *testing.T) {
+ cfg := config.Default()
+ cfg.Telnet.Enabled = true
+ cfg.Telnet.Port = 0
+ s, err := NewServer(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Shutdown()
+
+ observed := make(chan bool, 1)
+ s.handler = func(sess *Session, _ string) { observed <- sess.outbox != nil }
+ go s.serveTelnet()
+
+ conn, err := stdnet.Dial("tcp", s.telnetLn.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ conn.Write([]byte("hello\n"))
+
+ select {
+ case queued := <-observed:
+ if !queued {
+ t.Fatal("telnet session has no outbound queue; writes are synchronous and can stall the world")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("session handler was not invoked")
+ }
+}