package net import ( "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "fmt" "log" "math/big" "net" "net/http" "sort" "sync" "sync/atomic" "time" "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" "thehouseoficarus/internal/player" ) type SessionState int const ( StateAccountName SessionState = iota StatePassword StateNewAccountPass StateNewAccount StateMenu StateNewCharName StateNewCharConfirm StateRenameAccount StateRenameChar StateRenameCharName StateDeleteChar StatePurgeAccount StateGame StateChangeDesc StateTalk StateTalkSequence StateDropAllConfirm StateRecipeChoice StateHowMany StateSmithProduct StateFletchProduct StateCraftProduct StateProductChoice StateColorChoice StateBank StateHacking StateDangerConfirm StateUndigConfirm ) type Session struct { Conn Conn state atomic.Int32 Account *player.Account Player *player.Player PendingChar string PendingPass string PendingMenu []map[string]string PendingItemID string PendingSkill string PendingLastFlag string PendingBackground bool PendingUndigDir string PendingUndigRoom int promptVisible bool writeMu sync.Mutex // Disconnect bookkeeping is written by the connection goroutine that // discovered the dead transport (Hub.Remove's deferred path) and read // by the game's per-tick DisconnectTick on the owner goroutine, so both // fields are atomics. Plain fields would be a data race (and, worse, a // lost disconnect: the owner's tick could spin forever on a stale // value). disconnecting atomic.Bool disconnectTicks atomic.Int64 // authName carries the account name for the password-entry state. The // owner goroutine sets it when offering the password prompt, while the // connection goroutine reads it for the off-owner password // verification — so unlike Session.Account (which ONLY the owner may // touch, and which account republish can replace concurrently), it is // an atomic: a plain field read off the owner is a data race. authName atomic.Pointer[string] // Outbound queueing. Transport sessions (telnet/TLS/websocket) are put // into queued mode by startWriter before their input goroutine begins: // Write* then enqueues into outbox, and the dedicated writer goroutine // performs the network I/O with a per-message write deadline. A stalled // or slow client therefore blocks at most its own queue, never the // 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 // 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{} // seq is a monotonically increasing sequence number assigned when the // session is added to a Hub. Hub.AllSessions and Hub.PlayersInRoom sort // sessions by seq so per-tick consumers iterate players in a stable, // registration order instead of Go's randomized map iteration order. // Sessions constructed without being added (e.g. in tests) keep seq=0 // and tie-break by map-append order, which is also deterministic. seq uint64 } // State returns the session's current protocol state. Safe on any // goroutine: the owner writes the state while connection goroutines read // it in the HandleSessionOnOwner dispatch, so a plain field was a data // race (reproduced by the logout-countdown input regression test). func (sess *Session) State() SessionState { return SessionState(sess.state.Load()) } // SetState transitions the session to a new protocol state. // Owner goroutine only. func (sess *Session) SetState(s SessionState) { sess.state.Store(int32(s)) } // SetAuthName records the account name offered for password entry. // Owner goroutine only. func (sess *Session) SetAuthName(name string) { sess.authName.Store(&name) } // AuthName returns the account name recorded for password entry, or "" // when none was recorded. Safe on the connection goroutine. func (sess *Session) AuthName() string { if p := sess.authName.Load(); p != nil { return *p } return "" } // sessionSeq is the source of Session.seq values. It is incremented under the // adding Hub's mutex, so no extra atomicity is needed for correctness; atomic // access keeps the package-level default counter safe if Hub.Add is ever // called concurrently across hubs (it isn't today, but this is defensive). var sessionSeq atomic.Uint64 type Server struct { config *config.Config telnetLn net.Listener telnetTLSLn net.Listener httpServer *http.Server httpsServer *http.Server httpsTLSConfig *tls.Config hub *Hub handler func(*Session, string) done chan struct{} shutdownOnce sync.Once // finishOnce guards the single close of done: both the graceful // shutdown path and a fatal transport failure (a listener that cannot // bind, an Accept loop that dies) converge on finish, so ListenAndServe // unblocks either way and done is never closed twice. finishOnce sync.Once listenErr error // stopping flips when Shutdown begins so listener goroutines treat // their subsequent Accept/serve errors as expected, not fatal. stopping atomic.Bool // conns counts live sessions across every transport. handleSession // admits against maxConnections so a pre-auth connection flood cannot // hold an unbounded number of reader goroutines, buffers, and outboxes. conns atomic.Int64 // idleTimeout returns the read timeout armed before each message. It is a // field so tests can drive the idle-drop path without waiting out the // production windows; nil means sessionIdleTimeout. idleTimeout func(*Session) time.Duration } type Hub struct { mu sync.Mutex sessions map[*Session]bool rooms map[int]map[*Session]bool onRemove func(*Session) onDisconnect func(*Session) } // writeQueueBounds bound the outbound queue: maxQueuedMessages before a // client is disconnected for falling behind, writeDeadline per message, and // closeFlushTimeout to drain the queue when the session is closing. const ( maxQueuedMessages = 512 writeDeadline = 5 * time.Second closeFlushTimeout = 2 * time.Second ) // Connection admission and idle-read limits. The listener admits at most // maxConnections live sessions; each read arms an idle deadline (short before // authentication, generous once the session has an account) that every // message resets, so a silent client cannot pin a reader goroutine forever // while legitimate long sessions keep working. const ( maxConnections = 1000 loginReadTimeout = 2 * time.Minute idleReadTimeout = 30 * time.Minute ) // admitConnection reserves a connection slot, returning false when the server // is at capacity. Every admitted connection must call releaseConnection. func (s *Server) admitConnection() bool { if s.conns.Add(1) > maxConnections { s.conns.Add(-1) return false } return true } func (s *Server) releaseConnection() { s.conns.Add(-1) } // sessionIdleTimeout returns the read timeout to arm for this session. func (s *Server) sessionIdleTimeout(sess *Session) time.Duration { if s.idleTimeout != nil { return s.idleTimeout(sess) } return sessionIdleTimeout(sess) } // sessionIdleTimeout returns the read timeout for a session: the account-name // and password prompts get a short window (pre-auth sockets are the ones an // anonymous flood targets), everything else gets a generous idle timeout. func sessionIdleTimeout(sess *Session) time.Duration { switch sess.State() { case StateAccountName, StatePassword: return loginReadTimeout default: return idleReadTimeout } } // armReadDeadline sets an idle read deadline on the session's transport when // it supports one. tcpConn and wsConn (both in this package) expose their // underlying connection, so telnet, telnet-TLS, and websocket are all covered. func armReadDeadline(sess *Session, d time.Duration) { deadline := time.Now().Add(d) switch c := sess.Conn.(type) { case *tcpConn: _ = c.conn.SetReadDeadline(deadline) case *wsConn: _ = c.conn.SetReadDeadline(deadline) } } // 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 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) writeOne := func(msg string) bool { if dl, ok := sess.Conn.(interface { SetWriteDeadline(time.Time) error }); ok { _ = dl.SetWriteDeadline(time.Now().Add(writeDeadline)) } if _, err := sess.Conn.Write([]byte(msg)); err != nil { log.Printf("session write error: %v", err) // Abandon the queue and drop the transport; the input // goroutine notices the closed connection on its next // read and performs normal removal. sess.Conn.Close() return false } return true } 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 } } } } }() }) } // 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. 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) { select { case <-sess.closeCh: return default: } select { case sess.outbox <- msg: default: log.Printf("session write queue overflow; disconnecting slow client") sess.Close() } } func NewHub() *Hub { return &Hub{ sessions: make(map[*Session]bool), rooms: make(map[int]map[*Session]bool), } } // StartDisconnect arms the graceful-disconnect countdown. Called by the // connection goroutine that discovered the dead transport; DisconnectTick // (on the game owner) counts the session down and hard-removes it. func (sess *Session) StartDisconnect(ticks int) { sess.disconnectTicks.Store(int64(ticks)) sess.disconnecting.Store(true) } // IsDisconnecting reports whether the graceful-disconnect countdown has // been armed. Safe to call from any goroutine. func (sess *Session) IsDisconnecting() bool { return sess.disconnecting.Load() } // TickDisconnectCountdown decrements the armed countdown and reports // whether it has expired. Called only from the game's DisconnectTick // (owner goroutine); safe against a concurrent StartDisconnect. func (sess *Session) TickDisconnectCountdown() bool { return sess.disconnectTicks.Add(-1) <= 0 } func (h *Hub) OnRemove(cb func(*Session)) { h.mu.Lock() defer h.mu.Unlock() h.onRemove = cb } func (h *Hub) OnDisconnect(cb func(*Session)) { h.mu.Lock() defer h.mu.Unlock() h.onDisconnect = cb } func (h *Hub) Add(s *Session) { h.mu.Lock() defer h.mu.Unlock() s.seq = sessionSeq.Add(1) h.sessions[s] = true } // SetSessionPlayer assigns a session's live-character pointer while holding the // Hub mutex. The owner goroutine writes Player here; Hub.Remove (a connection // goroutine) reads it under the same mutex, so the pointer is synchronized // instead of a data race. func (h *Hub) SetSessionPlayer(s *Session, p *player.Player) { h.mu.Lock() s.Player = p h.mu.Unlock() } func (h *Hub) Remove(s *Session) { h.mu.Lock() if s.Player != nil && !s.IsDisconnecting() { s.StartDisconnect(10) onDisconnect := h.onDisconnect h.mu.Unlock() if onDisconnect != nil { onDisconnect(s) } return } callback := h.hardRemoveLocked(s) h.mu.Unlock() // The removal callback runs outside the Hub mutex: it performs saves // and game-state cleanup, and must never serialize behind (or hold up) // Hub membership operations. if callback != nil { callback(s) } } func (h *Hub) HardRemove(s *Session) { h.mu.Lock() callback := h.hardRemoveLocked(s) h.mu.Unlock() if callback != nil { callback(s) } } // hardRemoveLocked removes the session from all membership structures and // returns the removal callback for the caller to invoke AFTER releasing the // Hub mutex (nil when none is registered). func (h *Hub) hardRemoveLocked(s *Session) func(*Session) { delete(h.sessions, s) for _, room := range h.rooms { delete(room, s) } return h.onRemove } func (h *Hub) EnterRoom(s *Session, roomID int) { h.mu.Lock() defer h.mu.Unlock() h.leaveRoomLocked(s) if h.rooms[roomID] == nil { h.rooms[roomID] = make(map[*Session]bool) } h.rooms[roomID][s] = true } func (h *Hub) LeaveRoom(s *Session) { h.mu.Lock() defer h.mu.Unlock() h.leaveRoomLocked(s) } func (h *Hub) leaveRoomLocked(s *Session) { for _, room := range h.rooms { delete(room, s) } } // RekeyRooms remaps Hub room membership buckets per idMap (oldID -> newID). // Uses delete-all-then-set-all so a swap (A<->B) cannot clobber an entry // mid-rekey. Sessions' own player state is migrated by the game layer; this // only moves the membership buckets. func (h *Hub) RekeyRooms(idMap map[int]int) { h.mu.Lock() defer h.mu.Unlock() if len(idMap) == 0 { return } moved := make(map[int]map[*Session]bool, len(h.rooms)) for id, set := range h.rooms { if newID, ok := idMap[id]; ok && newID != id { moved[newID] = set delete(h.rooms, id) } } for newID, set := range moved { h.rooms[newID] = set } } func (h *Hub) AllSessions() []*Session { h.mu.Lock() defer h.mu.Unlock() var out []*Session for s := range h.sessions { out = append(out, s) } sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq }) return out } func (h *Hub) PlayersInRoom(roomID int) []*Session { h.mu.Lock() defer h.mu.Unlock() var out []*Session if room, ok := h.rooms[roomID]; ok { for s := range room { out = append(out, s) } } sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq }) return out } func NewServer(cfg *config.Config) (*Server, error) { s := &Server{ config: cfg, hub: NewHub(), done: make(chan struct{}), } if cfg.Telnet.Enabled { addr := fmt.Sprintf(":%d", cfg.Telnet.Port) ln, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("telnet listen: %w", err) } s.telnetLn = ln } if cfg.TelnetTLS.Enabled { cert, err := loadOrGenerateCert(cfg, "telnet_tls") if err != nil { return nil, fmt.Errorf("telnet_tls cert: %w", err) } tlsCfg := &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, } addr := fmt.Sprintf(":%d", cfg.TelnetTLS.Port) ln, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("telnet_tls listen: %w", err) } s.telnetTLSLn = tls.NewListener(ln, tlsCfg) } if cfg.HTTP.Enabled { s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.HTTP.Port), Handler: newHTTPMux(s), } } if cfg.HTTPS.Enabled { cert, err := loadOrGenerateCert(cfg, "https") if err != nil { return nil, fmt.Errorf("https cert: %w", err) } s.httpsTLSConfig = &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, } s.httpsServer = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port), Handler: newHTTPMux(s), } } return s, nil } func loadOrGenerateCert(cfg *config.Config, service string) (tls.Certificate, error) { if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) } log.Printf("%s: using self-signed certificate (no shared tls cert configured)", service) return generateSelfSignedCert() } func generateSelfSignedCert() (tls.Certificate, error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err) } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return tls.Certificate{}, fmt.Errorf("serial number: %w", err) } now := time.Now() template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: "THOI Self-Signed Certificate", }, NotBefore: now.Add(-1 * time.Hour), NotAfter: now.Add(365 * 24 * time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, } certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err) } return tls.Certificate{ Certificate: [][]byte{certDER}, PrivateKey: key, }, nil } func (s *Server) Hub() *Hub { return s.hub } func (s *Server) ListenAndServe(handler func(*Session, string)) error { s.handler = handler if s.telnetLn != nil { go s.serveTelnet() } if s.telnetTLSLn != nil { go s.serveTelnetTLS() } if s.httpServer != nil { go s.serveHTTP() } if s.httpsServer != nil { go s.serveHTTPS() } if s.telnetLn == nil && s.telnetTLSLn == nil && s.httpServer == nil && s.httpsServer == nil { return fmt.Errorf("no listeners configured") } // Block until Shutdown is called (or a fatal listener error occurs). <-s.done return s.listenErr } // finish records the terminal transport error (nil for a clean shutdown) // exactly once and unblocks ListenAndServe. Fatal transport failures — a // listener that cannot bind or an Accept loop that dies outside shutdown — // reach it too, so a misconfigured server does not sit running with no way // in. func (s *Server) finish(err error) { s.finishOnce.Do(func() { s.listenErr = err close(s.done) }) } // Shutdown stops all transports: HTTP(S) servers drain, listeners close, and // every session is closed (its outbound queue drains first). ListenAndServe // returns after Shutdown completes. It is safe to call more than once. func (s *Server) Shutdown() { s.shutdownOnce.Do(func() { // Before touching any listener: goroutines serving those listeners // check this flag so their resulting errors are not reported as // fatal. s.stopping.Store(true) if s.httpServer != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = s.httpServer.Shutdown(ctx) } if s.httpsServer != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = s.httpsServer.Shutdown(ctx) } if s.telnetLn != nil { s.telnetLn.Close() } if s.telnetTLSLn != nil { s.telnetTLSLn.Close() } for _, sess := range s.hub.AllSessions() { sess.Close() } // Give each writer goroutine a bounded window to flush its outbound // queue before the process exits; otherwise shutdown and refund // messages can be truncated for slower clients. flushDeadline := time.Now().Add(2 * time.Second) for _, sess := range s.hub.AllSessions() { if sess.writeDone == nil { continue } remaining := time.Until(flushDeadline) if remaining <= 0 { break } select { case <-sess.writeDone: case <-time.After(remaining): } } s.finish(nil) }) <-s.done } func (s *Server) serveTelnet() { for { conn, err := s.telnetLn.Accept() if err != nil { if !s.stopping.Load() { log.Printf("telnet listener error: %v", err) s.finish(fmt.Errorf("telnet listener: %w", err)) } return } sess := &Session{ Conn: newTCPConn(conn), } sess.SetState(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) } } func (s *Server) serveTelnetTLS() { for { conn, err := s.telnetTLSLn.Accept() if err != nil { if !s.stopping.Load() { log.Printf("telnet+tls listener error: %v", err) s.finish(fmt.Errorf("telnet+tls listener: %w", err)) } return } sess := &Session{ Conn: newTCPConn(conn), } sess.SetState(StateAccountName) sess.StartWriter() s.hub.Add(sess) go s.handleSession(sess, s.handler) } } func (s *Server) serveHTTP() { err := s.httpServer.ListenAndServe() if err != nil && err != http.ErrServerClosed { log.Printf("http server error: %v", err) if !s.stopping.Load() { s.finish(fmt.Errorf("http server: %w", err)) } } } func (s *Server) serveHTTPS() { addr := fmt.Sprintf(":%d", s.config.HTTPS.Port) ln, err := net.Listen("tcp", addr) if err != nil { log.Printf("https server error: %v", err) if !s.stopping.Load() { s.finish(fmt.Errorf("https listen: %w", err)) } return } tlsLn := tls.NewListener(ln, s.httpsTLSConfig) if err := s.httpsServer.Serve(tlsLn); err != nil && err != http.ErrServerClosed { log.Printf("https server error: %v", err) if !s.stopping.Load() { s.finish(fmt.Errorf("https server: %w", err)) } } } func (s *Server) handleSession(sess *Session, handler func(*Session, string)) { defer func() { if r := recover(); r != nil { log.Printf("session panic: %v", r) } s.hub.Remove(sess) sess.Close() }() // Admit before reading anything: an over-capacity connection (including // one arriving through the websocket transport, whose handler shares this // path) is told so and dropped without holding a read loop. if !s.admitConnection() { sess.Write("Server is at full capacity. Please try again later.\r\n") return } defer s.releaseConnection() sess.Write("\033[2J\033[H") sess.Write(WelcomeBanner()) sess.Write("What's your account name? ") for { // Re-armed every iteration so any message from the client resets the // idle countdown. armReadDeadline(sess, s.sessionIdleTimeout(sess)) line, err := sess.Conn.ReadMessage() if err != nil { log.Printf("session read error: %v", err) return } if len(line) > 1024 { line = line[:1024] } handler(sess, line) } } // wrap applies the player's wrap_width option to outgoing text. It is a no-op // before login (no Player) so the welcome banner is never reflowed. The width // is clamped to a minimum of 80 columns. func (sess *Session) wrap(msg string) string { if sess.Player == nil { return msg } width := sess.Player.OptionInt("wrap_width") if width < 80 { width = 80 } return color.WrapANSI(msg, width) } // ClearPrompt marks the prompt as consumed so the next Write/WriteLine does not // emit a leading \r\n. Useful for clients that locally advance the line on Enter. func (sess *Session) ClearPrompt() { sess.writeMu.Lock() defer sess.writeMu.Unlock() sess.promptVisible = false } // dispatch delivers a fully-formatted message to the transport: enqueued in // queued mode (the writer goroutine owns the network), written synchronously // in direct mode (tests, synthetic conns). Caller holds writeMu. func (sess *Session) dispatch(msg string) { if sess.outbox != nil { sess.enqueue(msg) return } 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() defer sess.writeMu.Unlock() if sess.promptVisible { sess.dispatch("\r\n") sess.promptVisible = false } sess.dispatch(msg) } func (sess *Session) WriteLine(msg string) { msg = sess.wrap(msg) sess.writeMu.Lock() defer sess.writeMu.Unlock() if sess.promptVisible { sess.dispatch("\r\n") sess.promptVisible = false } sess.dispatch(msg + "\r\n") } func (sess *Session) WriteLines(lines ...string) { sess.writeMu.Lock() defer sess.writeMu.Unlock() for i, l := range lines { if i == 0 && sess.promptVisible { sess.dispatch("\r\n") sess.promptVisible = false } sess.dispatch(sess.wrap(l) + "\r\n") } } func (sess *Session) Writef(format string, args ...interface{}) { msg := sess.wrap(fmt.Sprintf(format, args...)) sess.writeMu.Lock() defer sess.writeMu.Unlock() if sess.promptVisible { sess.dispatch("\r\n") sess.promptVisible = false } sess.dispatch(msg) } func (sess *Session) WritePrompt(prompt string) { sess.writeMu.Lock() defer sess.writeMu.Unlock() if sess.promptVisible { return } sess.dispatch(prompt) sess.promptVisible = true } func (sess *Session) Reprompt(prompt string) { sess.writeMu.Lock() defer sess.writeMu.Unlock() sess.dispatch(prompt) sess.promptVisible = true } // 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 { var err error sess.closeOnce.Do(func() { if sess.closeCh != nil { close(sess.closeCh) return } err = sess.Conn.Close() }) return err }