From 9192d94dea6be5498ab080f3e2df5667af871d7a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 16 Sep 2026 14:41:15 -0400 Subject: fix: casino room eviction, session close race fix, open-payout recovery evidence --- internal/net/session_close_test.go | 73 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 internal/net/session_close_test.go (limited to 'internal/net/session_close_test.go') diff --git a/internal/net/session_close_test.go b/internal/net/session_close_test.go new file mode 100644 index 0000000..7cb54b6 --- /dev/null +++ b/internal/net/session_close_test.go @@ -0,0 +1,73 @@ +package net + +// Close/broadcast race regression: a broadcast racing Session.Close() used +// to send on the outbound channel while Close closed the same channel — a +// genuine data race (closechan vs chansend) that the old panic-recover only +// hid. Closing is now signaled through a separate channel that is only ever +// closed, never sent to. + +import ( + stdnet "net" + "sync" + "testing" + "time" +) + +// newTestSocketPair returns a synchronous, unbuffered connection pair: a +// write blocks until the peer reads, which is exactly the stall condition +// the echo test has to reproduce. +func newTestSocketPair() (stdnet.Conn, stdnet.Conn) { return stdnet.Pipe() } + +func testTimeout() <-chan time.Time { return time.After(200 * time.Millisecond) } + +func TestConcurrentWriteCloseIsRaceFree(t *testing.T) { + for i := 0; i < 50; i++ { + sess := &Session{Conn: &testConn{}} + sess.StartWriter() + var wg sync.WaitGroup + wg.Add(2) + start := make(chan struct{}) + go func() { + defer wg.Done() + <-start + for j := 0; j < 100; j++ { + sess.WriteLine("broadcast") + } + }() + go func() { + defer wg.Done() + <-start + sess.Close() + }() + close(start) + wg.Wait() + <-sess.writeDone + } +} + +// Echo negotiation regression: login flips telnet echo on the game owner's +// goroutine. Writing the negotiation inline made that write a direct socket +// I/O, so a client that never read (full receive window, stalled socket) +// blocked the entire game loop behind one connection. Queued sessions must +// route the negotiation through the outbound writer instead. +func TestSetEchoQueuedDoesNotBlockCaller(t *testing.T) { + local, peer := newTestSocketPair() + defer local.Close() + defer peer.Close() + sess := &Session{Conn: newTCPConn(local)} + sess.StartWriter() + defer sess.Close() + + done := make(chan struct{}) + go func() { + sess.SetEcho(false) // what the login path calls for password entry + close(done) + }() + select { + case <-done: + case <-testTimeout(): + peer.Close() // unblock the writer so the test can exit + <-done + t.Fatal("SetEcho blocked the caller instead of queueing the negotiation") + } +} -- cgit v1.2.3