aboutsummaryrefslogtreecommitdiff
path: root/internal/net/server.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 14:41:15 -0400
committerhistoria <[not public]>2026-09-16 14:41:15 -0400
commit9192d94dea6be5498ab080f3e2df5667af871d7a (patch)
treee7aef3089d5d1e8da686c6b6ca9d4faeacb8b20b /internal/net/server.go
parent2f34e75f27e5f99cfc1c5f8eaf6ab4ff2fddb1d9 (diff)
downloadthehouseoficarus-9192d94dea6be5498ab080f3e2df5667af871d7a.tar.gz
fix: casino room eviction, session close race fix, open-payout recovery evidence
Diffstat (limited to 'internal/net/server.go')
-rw-r--r--internal/net/server.go108
1 files changed, 81 insertions, 27 deletions
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
}