From fcb806c3445ce56f2e57e208de773b4dac957c8d Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 20:35:23 -0400 Subject: fix: owner re-entrancy deadlock, telnet write queue, recovery/refund money safety, admin path traversal --- internal/net/server.go | 13 +++++++ internal/net/session_writer_test.go | 67 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 internal/net/session_writer_test.go (limited to 'internal/net') 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") + } +} -- cgit v1.2.3