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/server.go | 108 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 81 insertions(+), 27 deletions(-) (limited to 'internal/net/server.go') diff --git a/internal/net/server.go b/internal/net/server.go index adc828c..dd65f6e 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -89,7 +89,12 @@ type Session struct { // world; an overflowing queue disconnects only that client. Sessions // without a writer (tests, synthetic conns) keep the legacy synchronous // path so message construction is shared and behavior identical. - outbox chan string + outbox chan string + // closeCh is closed by Close() to tell the writer goroutine to drain the + // queue and then close the transport. It is ONLY ever closed, never sent + // to: enqueues select on it to drop late writes, so a close can never + // race a concurrent send the way closing outbox itself did. + closeCh chan struct{} writerOnce sync.Once closeOnce sync.Once writeDone chan struct{} @@ -143,16 +148,18 @@ const ( // startWriter puts the session into queued mode: writes are enqueued and a // dedicated goroutine drains the queue to the network with per-message write -// deadlines. The goroutine closes the transport after the queue drains, so a -// Close() during shutdown flushes pending output before the socket drops. +// deadlines. The goroutine exits after Close() — draining whatever was +// queued first — and closes the transport, so a Close() during shutdown +// flushes pending output before the socket drops. // Must be called before the session's input goroutine starts. func (sess *Session) StartWriter() { sess.writerOnce.Do(func() { sess.outbox = make(chan string, maxQueuedMessages) + sess.closeCh = make(chan struct{}) sess.writeDone = make(chan struct{}) go func() { defer close(sess.writeDone) - for msg := range sess.outbox { + writeOne := func(msg string) bool { if dl, ok := sess.Conn.(interface { SetWriteDeadline(time.Time) error }); ok { @@ -164,32 +171,52 @@ func (sess *Session) StartWriter() { // goroutine notices the closed connection on its next // read and performs normal removal. sess.Conn.Close() - return + return false } + return true } - // Queue closed and drained: flush any final bytes and close. - if dl, ok := sess.Conn.(interface { - SetWriteDeadline(time.Time) error - }); ok { - _ = dl.SetWriteDeadline(time.Now().Add(closeFlushTimeout)) + for { + select { + case msg := <-sess.outbox: + if !writeOne(msg) { + return + } + case <-sess.closeCh: + // Drain whatever was queued before the close, then + // flush any final bytes and close the transport. + for { + select { + case msg := <-sess.outbox: + if !writeOne(msg) { + return + } + default: + if dl, ok := sess.Conn.(interface { + SetWriteDeadline(time.Time) error + }); ok { + _ = dl.SetWriteDeadline(time.Now().Add(closeFlushTimeout)) + } + sess.Conn.Close() + return + } + } + } } - sess.Conn.Close() }() }) } // 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. +// never wait on a single slow client. Writes racing a Close() are dropped, +// never panicked on: closeCh is only ever closed (never sent to), and outbox +// itself is never closed, so no close/send race exists. 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.closeCh: + return + default: + } select { case sess.outbox <- msg: default: @@ -628,6 +655,31 @@ func (sess *Session) dispatch(msg string) { sess.Conn.Write([]byte(msg)) } +// echoController is implemented by transports whose echo negotiation can be +// expressed as bytes: queued sessions send those bytes through the session +// writer so a stalled client cannot block the caller (the game owner), +// while the local echo state is applied immediately via SetEchoState. +type echoController interface { + EchoSequence(on bool) []byte + SetEchoState(on bool) +} + +// SetEcho toggles client echo. Queued sessions transmit the negotiation +// through the outbound queue (keeping prompt bookkeeping untouched); direct +// sessions and transports without byte-expressible negotiation write inline. +func (sess *Session) SetEcho(on bool) { + if sess.outbox != nil { + if ec, ok := sess.Conn.(echoController); ok { + ec.SetEchoState(on) + sess.writeMu.Lock() + defer sess.writeMu.Unlock() + sess.dispatch(string(ec.EchoSequence(on))) + return + } + } + _ = sess.Conn.SetEcho(on) +} + // Write sends raw text with no wrapping. Used for prompts and partial lines. func (sess *Session) Write(msg string) { sess.writeMu.Lock() @@ -690,15 +742,17 @@ func (sess *Session) Reprompt(prompt string) { sess.promptVisible = true } -// Close stops the session. In queued mode it closes the queue so the writer -// goroutine drains pending messages and then closes the transport; in direct +// Close stops the session. In queued mode it signals the writer goroutine, +// which drains pending messages and then closes the transport; in direct // mode it closes the transport immediately. Idempotent. func (sess *Session) Close() error { - if sess.outbox != nil { - sess.closeOnce.Do(func() { close(sess.outbox) }) - return nil - } var err error - sess.closeOnce.Do(func() { err = sess.Conn.Close() }) + sess.closeOnce.Do(func() { + if sess.closeCh != nil { + close(sess.closeCh) + return + } + err = sess.Conn.Close() + }) return err } -- cgit v1.2.3