From d026a3092716f8353a0208ad68b50acde35944b2 Mon Sep 17 00:00:00 2001 From: Syahdan Date: Wed, 15 Oct 2025 23:14:03 +0700 Subject: [PATCH] =?UTF-8?q?feat(client):=20show=20local=20=E2=80=9C[order]?= =?UTF-8?q?=20=E2=80=A6=E2=80=9D=20entry=20after=20successful=20submit;=20?= =?UTF-8?q?introduce=20server=20line=20msg=20type;=20WIP=20reader=20state?= =?UTF-8?q?=20in=20broken=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add serverLineMsg type for future server line handling - On successful orderSubmittedMsg with total: - Derive item name from cached menu and append a formatted “[order] Name ordered N × Item ($Total)” to broadcasts - Keep broadcasts capped at 10 entries - Maintain existing ack handling when no total provided Also add a WIP snapshot (main.go.broken): - Replace broadcastListening flag with waitingForMenu and waitingForOrderResp - On connect, start read loop via readServerLineCmd(reader) - When submitting an order, set waitingForOrderResp and call submitOrderCmd without passing reader - After orderSubmittedMsg (success or error), resume listenForBroadcastsCmd if conn/reader present Note: main.go.broken reflects an in-progress refactor toward a single read loop and explicit waiting states. --- README.md | 664 +++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 78 +++--- main.go.broken | 592 ------------------------------------------- server.go | 2 +- 4 files changed, 712 insertions(+), 624 deletions(-) create mode 100644 README.md delete mode 100644 main.go.broken diff --git a/README.md b/README.md new file mode 100644 index 0000000..05185f3 --- /dev/null +++ b/README.md @@ -0,0 +1,664 @@ +# Clink - TCP Socket-Based Order System + +## Overview + +Clink is a real-time order management system demonstrating TCP socket programming concepts in Go. The application consists of a TCP server that manages client connections and broadcasts order updates, and a Terminal User Interface (TUI) client that connects to the server to place orders and receive real-time updates from all connected clients. + +**Main Topic:** Socket Programming with TCP in Go + +--- + +## Architecture + +### Components + +1. **TCP Server** (`server.go`) - Multi-client socket server with broadcast capability +2. **TUI Client** (`main.go`) - Interactive terminal client using TCP sockets +3. **Protocol** - Custom text-based protocol over TCP + +--- + +## Socket Programming Concepts Demonstrated + +### 1. TCP Server Socket Creation + +**Location:** `server.go:252-256` + +```go +func startTCPServer(addr string) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } +``` + +- Uses `net.Listen()` to create a TCP listener socket +- Binds to the specified address (default: `localhost:9000`) +- Returns a `net.Listener` that can accept incoming connections + +--- + +### 2. Accepting Client Connections + +**Location:** `server.go:261-268` + +```go +for { + c, err := ln.Accept() + if err != nil { + log.Printf("accept error: %v", err) + continue + } + go handleConn(hub, c) +} +``` + +- `Accept()` blocks until a client connects +- Each connection is handled in a separate goroutine +- This enables concurrent handling of multiple clients + +--- + +### 3. Client Socket Connection + +**Location:** `main.go:484-493` + +```go +func connectCmd(addr string) tea.Cmd { + return func() tea.Msg { + conn, err := net.DialTimeout("tcp", addr, 3*time.Second) + if err != nil { + return statusMsg(fmt.Sprintf("Connect failed: %v", err)) + } + return connectedMsg{conn: conn} + } +} +``` + +- Uses `net.DialTimeout()` to establish TCP connection +- Includes 3-second timeout to prevent indefinite blocking +- Returns `net.Conn` interface for bidirectional communication + +--- + +### 4. Buffered Reading from Socket + +**Location:** `main.go:148-150` + +```go +case connectedMsg: + m.conn = msg.conn + m.reader = bufio.NewReader(m.conn) +``` + +**Why Buffered Reading?** +- Raw socket reads are byte-level and inefficient +- `bufio.Reader` provides buffering and line-reading capabilities +- **Critical:** Single `bufio.Reader` instance per connection prevents data corruption +- Multiple readers on the same socket would compete for bytes + +--- + +### 5. Reading Greeting Messages + +**Location:** `main.go:153-159` + +```go +_ = m.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) +for i := 0; i < 2; i++ { + if _, err := m.reader.ReadString('\n'); err != nil { + break + } +} +_ = m.conn.SetReadDeadline(time.Time{}) +``` + +- Server sends 2 greeting lines upon connection (lines 131-132 in `server.go`) +- Client consumes these to prevent interference with protocol messages +- **Socket Deadline:** Temporary 500ms timeout prevents indefinite blocking +- Deadline is reset to zero (no timeout) after consuming greetings + +--- + +### 6. Writing to Socket + +**Location:** `main.go:506`, `main.go:544` + +```go +// Sending MENU request +fmt.Fprintln(conn, "MENU") + +// Sending ORDER request +fmt.Fprintf(conn, "ORDER %s\n", string(b)) +``` + +**Location:** `server.go:155`, `server.go:211` + +```go +// Server responses +fmt.Fprintln(c, string(b)) // MENU response +fmt.Fprintf(c, "OK|%.2f\n", total) // ORDER response +``` + +- `fmt.Fprintln()` and `fmt.Fprintf()` write to any `io.Writer`, including sockets +- Newline-delimited protocol (each message ends with `\n`) +- Text-based protocol for simplicity and debuggability + +--- + +### 7. Socket Read Deadlines (Timeouts) + +**Location:** `main.go:510-511`, `main.go:550-551`, `main.go:581-583` + +```go +// Set 3-second timeout for MENU +_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) +defer func() { _ = conn.SetReadDeadline(time.Time{}) }() + +// Set 5-second timeout for ORDER +_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + +// Set 100ms timeout for broadcasts (polling) +_ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) +``` + +**Why Different Timeouts?** +- **MENU/ORDER (3-5s):** Synchronous request-response, expect immediate reply +- **Broadcasts (100ms):** Asynchronous polling loop, short timeout prevents blocking +- Deadlines prevent reads from blocking forever if server stops responding + +**Deadline vs Timeout:** +- `SetReadDeadline()` sets an absolute time +- After deadline expires, read returns `net.Error` with `Timeout() == true` + +--- + +### 8. Handling Timeout Errors + +**Location:** `main.go:585-589` + +```go +if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + return broadcastMsg("") // Timeout is expected, continue polling + } + return statusMsg(fmt.Sprintf("Connection closed: %v", err)) +} +``` + +- Type assertion to check if error is `net.Error` +- Distinguish between timeout (expected) and connection failure +- Timeouts in broadcast loop are normal behavior (polling mechanism) + +--- + +### 9. Concurrent Socket Communication Challenge + +**The Problem:** Race condition between synchronous requests and asynchronous broadcasts + +**Location:** `main.go:130`, `main.go:548`, `main.go:213-215` + +```go +// Before ORDER: Pause broadcast listener +m.pauseBroadcast = true + +// Wait for broadcast listener's current read to timeout +time.Sleep(150 * time.Millisecond) + +// Broadcast handler: Slow down polling when paused +if m.pauseBroadcast { + time.Sleep(50 * time.Millisecond) +} +``` + +**Why This Is Needed:** +- Single TCP socket shared between request-response and broadcast listening +- Only one goroutine can read from socket at a time +- Without coordination, broadcast listener might consume ORDER response +- Solution: Coordinate reads using pause flag and timeouts + +**Timeline Without Coordination:** +``` +Time Action +t=0 ORDER sent +t=1 Broadcast listener reads "OK|9.00" (WRONG!) +t=3000 submitOrderCmd times out (never got response) +``` + +**Timeline With Coordination:** +``` +Time Action +t=0 pauseBroadcast = true +t=0 ORDER sent +t=150 Broadcast listener's 100ms timeout expires +t=150 Broadcast listener sees pause, sleeps 50ms +t=150 submitOrderCmd reads "OK|9.00" (SUCCESS!) +t=200 Broadcast listener resumes with pause = false +``` + +--- + +### 10. Hub Pattern for Broadcasting + +**Location:** `server.go:36-80` + +```go +type Hub struct { + mu sync.Mutex + conns map[net.Conn]struct{} + joinCh chan net.Conn + leaveCh chan net.Conn + msgCh chan broadcast +} + +func (h *Hub) Run() { + for { + select { + case c := <-h.joinCh: + h.mu.Lock() + h.conns[c] = struct{}{} + h.mu.Unlock() + case c := <-h.leaveCh: + h.mu.Lock() + if _, ok := h.conns[c]; ok { + delete(h.conns, c) + _ = c.Close() + } + h.mu.Unlock() + case msg := <-h.msgCh: + h.mu.Lock() + for c := range h.conns { + if msg.exclude != nil && c == msg.exclude { + continue + } + fmt.Fprintln(c, msg.text) // Write to each socket + } + h.mu.Unlock() + } + } +} +``` + +**Pattern Benefits:** +- Centralized connection management +- Thread-safe access to connection map using mutex +- Fan-out broadcast to all connected sockets +- Optional exclusion (don't echo back to sender) + +**Broadcasting an Order:** `server.go:207-209` +```go +h.msgCh <- broadcast{ + text: fmt.Sprintf("[order] %s ordered %d × %s ($%.2f)", + ord.Name, ord.Quantity, chosen.Name, total), +} +``` + +--- + +### 11. Per-Connection Handler + +**Location:** `server.go:115-248` + +```go +func handleConn(h *Hub, c net.Conn) { + defer func() { h.leaveCh <- c }() + h.joinCh <- c + + scanner := bufio.NewScanner(c) + scanner.Buffer(make([]byte, 0, 1024), 64*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Protocol handling... + } +} +``` + +**Key Points:** +- Runs in separate goroutine per connection +- `defer` ensures cleanup when connection closes +- Uses `bufio.Scanner` for line-by-line reading +- Handles multiple protocol commands: `MENU`, `ORDER`, `/name`, `/quit` + +--- + +### 12. Connection Lifecycle + +**Server Side:** +1. `ln.Accept()` - Accept incoming connection (`server.go:262`) +2. `h.joinCh <- c` - Register connection in hub (`server.go:117`) +3. Send greeting messages (`server.go:131-132`) +4. Process commands in loop (`server.go:141-240`) +5. `h.leaveCh <- c` - Unregister and close (`server.go:116`) + +**Client Side:** +1. `net.DialTimeout()` - Establish connection (`main.go:487`) +2. Create `bufio.Reader` (`main.go:150`) +3. Consume greeting messages (`main.go:153-159`) +4. Send protocol commands (`MENU`, `ORDER`) +5. Listen for broadcasts (`main.go:570-593`) +6. `conn.Close()` - Close on quit (`main.go:234`) + +--- + +## Protocol Specification + +### Text-Based Protocol over TCP + +All messages are newline-delimited (`\n`). + +#### Client → Server + +**1. MENU Request** +- Format: `MENU\n` +- Location: `main.go:506` +- Server handler: `server.go:149-157` +- Response: JSON array of menu items + +**Example:** +``` +Client: MENU +Server: [{"id":"latte","name":"Caffè Latte","price":4.5},...] +``` + +**2. ORDER Request** +- Format: `ORDER \n` +- Location: `main.go:544` +- Server handler: `server.go:160-213` +- Response: `OK|\n` + +**Example:** +``` +Client: ORDER {"name":"Alice","itemId":"latte","quantity":2} +Server: OK|9.00 +``` + +#### Server → Client (Broadcasts) + +**1. Order Broadcast** +- Format: `[order] ordered × ($)\n` +- Location: `server.go:207-209` +- Client handler: `main.go:205-216` + +**Example:** +``` +[order] Alice ordered 2 × Caffè Latte ($9.00) +``` + +**2. Join/Leave Broadcasts** +- Format: `[join] ()\n` or `[leave] ()\n` +- Location: `server.go:135`, `server.go:247` + +--- + +## Application Flow + +### Server Startup Flow + +``` +1. main() [server.go:595] + └─ Parse flags: --server + +2. startTCPServer() [server.go:251] + └─ net.Listen("tcp", addr) + +3. hub.Run() [server.go:54] + └─ Goroutine: Handle join/leave/broadcast channels + +4. Accept loop [server.go:261-268] + └─ For each connection: + └─ go handleConn(hub, conn) + ├─ Register: hub.joinCh <- conn + ├─ Send greeting [131-132] + ├─ Process commands [141-240] + │ ├─ MENU → JSON response [149-157] + │ ├─ ORDER → Validate, broadcast, ack [160-213] + │ └─ /name, /quit [216-236] + └─ Cleanup: hub.leaveCh <- conn [116] +``` + +--- + +### Client Startup Flow + +``` +1. main() [main.go:595] + └─ tea.NewProgram(initialModel(host)) + +2. Init() [main.go:88-91] + └─ Return connectCmd(host) + +3. connectCmd() [main.go:484-493] + └─ net.DialTimeout("tcp", addr, 3s) + └─ Return connectedMsg{conn} + +4. Update(connectedMsg) [main.go:148-161] + ├─ Create bufio.Reader(conn) + ├─ Consume 2 greeting lines + └─ Status: "Connected" +``` + +--- + +### Order Submission Flow (Client) + +``` +1. User presses 'n' [main.go:247] + └─ If menu cached: Show form + └─ Else: fetchMenuCmd() + +2. fetchMenuCmd() [main.go:500-527] + ├─ Write: "MENU\n" + ├─ SetReadDeadline(3s) + └─ Read JSON response + +3. Update(menuLoadedMsg) [main.go:163-175] + └─ Build and show order form + +4. User fills form [name, item, quantity, confirm] + └─ Form completed [main.go:107-135] + +5. If confirmed: + ├─ Set pauseBroadcast = true [130] + └─ submitOrderCmd() [534-567] + ├─ Marshal order to JSON + ├─ Write: "ORDER \n" + ├─ Sleep 150ms (coordination) + ├─ SetReadDeadline(5s) + └─ Read: "OK|\n" + +6. Update(orderSubmittedMsg) [main.go:177-203] + ├─ Set pauseBroadcast = false + ├─ Display success status + ├─ If first order: + │ └─ Start broadcast listener + └─ Else: + └─ Resume broadcast listener +``` + +--- + +### Broadcast Listening Flow (Client) + +``` +1. Start after first order [main.go:192-194] + └─ Set broadcastListening = true + +2. listenForBroadcastsCmd() [main.go:570-593] + ├─ SetReadDeadline(100ms) # Poll with timeout + ├─ line, err := reader.ReadString('\n') + └─ Three outcomes: + ├─ Timeout → Return broadcastMsg("") + ├─ Error → Connection closed + └─ Success → Return broadcastMsg(line) + +3. Update(broadcastMsg) [main.go:205-216] + ├─ If "[order]" prefix: + │ └─ Append to broadcasts list (max 10) + ├─ If pauseBroadcast: + │ └─ Sleep 50ms (slow down polling) + └─ Restart: listenForBroadcastsCmd() +``` + +**Polling Loop Visualization:** +``` +Time (ms) Action +0 SetReadDeadline(+100ms) +0-100 Blocking read... +100 Timeout → broadcastMsg("") +100 Handle message, restart +100 SetReadDeadline(+100ms) +100-200 Blocking read... +150 [Data arrives: "[order] Alice..."] +150 Success → broadcastMsg("[order]...") +150 Handle message, append to list +150 Restart loop +``` + +--- + +### Order Processing Flow (Server) + +``` +1. Receive: "ORDER \n" [server.go:160] + +2. Parse and validate [server.go:161-203] + ├─ json.Unmarshal(raw, &ord) + ├─ Check: ord.Name != "" + ├─ Check: ord.Quantity > 0 + └─ Lookup: Find menuItem by ord.ItemID + +3. Calculate total [server.go:205] + └─ total = quantity × price + +4. Broadcast to all clients [server.go:207-209] + └─ hub.msgCh <- broadcast{...} + └─ Hub writes to ALL sockets [server.go:68-77] + └─ fmt.Fprintln(c, msg.text) + +5. Acknowledge to sender [server.go:211] + └─ Write: "OK|\n" +``` + +--- + +## Key Socket Programming Challenges & Solutions + +### Challenge 1: Single Socket, Dual Purpose + +**Problem:** Same socket used for: +- Synchronous request-response (MENU, ORDER) +- Asynchronous broadcast receiving + +**Solution:** +- Broadcast listener uses short (100ms) timeouts +- Before synchronous requests: pause broadcast polling +- Coordination via `pauseBroadcast` flag and sleep delays +- Locations: `main.go:130`, `main.go:548`, `main.go:213-215` + +--- + +### Challenge 2: Buffered Reader Data Corruption + +**Problem:** Multiple `bufio.Reader` instances on same socket would: +- Compete for bytes from socket buffer +- Corrupt message boundaries +- Cause incomplete/split messages + +**Solution:** +- Single `bufio.Reader` created once at connection (`main.go:150`) +- Shared across all read operations +- Passed to all command functions + +--- + +### Challenge 3: Greeting Message Interference + +**Problem:** Server sends 2 greeting lines on connect +- Would be parsed as protocol responses +- MENU/ORDER reads would get greeting instead of response + +**Solution:** +- Consume greetings immediately after connect (`main.go:153-159`) +- Use short deadline (500ms) to prevent blocking +- Clear socket buffer before protocol starts + +--- + +### Challenge 4: Connection Cleanup + +**Problem:** Connections must be cleaned up on: +- Client disconnect +- Server shutdown +- Network errors + +**Solution (Server):** +- `defer func() { h.leaveCh <- c }()` guarantees cleanup (`server.go:116`) +- Hub centralizes close logic (`server.go:61-67`) + +**Solution (Client):** +- Detect "Connection closed" in status messages (`main.go:220-227`) +- Reset all socket-related state: `conn`, `reader`, `broadcastListening` + +--- + +## Summary + +This application demonstrates: + +1. **TCP Client-Server Architecture** + - Server: `net.Listen()`, `Accept()`, per-connection goroutines + - Client: `net.DialTimeout()`, shared connection + +2. **Custom Text Protocol** + - Newline-delimited messages + - Request-response (MENU, ORDER) + - Server-initiated broadcasts + +3. **Concurrent Socket Communication** + - Multiple clients handled concurrently (server) + - Synchronous + asynchronous reads on single socket (client) + +4. **Socket I/O Techniques** + - Buffered reading with `bufio.Reader` + - Read deadlines for timeouts + - Formatted writing with `fmt.Fprintf()` + +5. **Broadcast Pattern** + - Hub with channels for thread-safe fan-out + - Write to multiple sockets from single goroutine + +6. **Real-World Challenges** + - Read coordination between sync/async operations + - Connection lifecycle management + - Error handling and recovery + +--- + +## Running the Application + +**Start Server:** +```bash +go run . -server -host localhost:9000 +``` + +**Start Client(s):** +```bash +go run . -host localhost:9000 +``` + +**Client Controls:** +- `n` - New order (loads menu if needed) +- `r` - Reconnect +- `q` - Quit + +--- + +## File Structure + +``` +clink/ +├── main.go # TUI client with socket operations +├── server.go # TCP server with hub pattern +└── README.md # This documentation +``` diff --git a/main.go b/main.go index 55f3017..870dcb7 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,7 @@ type model struct { reader *bufio.Reader broadcastListening bool + pauseBroadcast bool } // initialModel creates a base model. @@ -126,6 +127,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.err = nil m.loading = true + m.pauseBroadcast = true m.status = "Submitting order..." return m, submitOrderCmd(m.conn, *ord, m.reader) } @@ -169,62 +171,60 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.menu = msg.items m.status = "Menu loaded." - var cmds []tea.Cmd m.form = m.buildForm() - cmds = append(cmds, m.form.Init()) - - if !m.broadcastListening { - m.broadcastListening = true - cmds = append(cmds, listenForBroadcastsCmd(m.conn, m.reader)) - } - - return m, tea.Batch(cmds...) + return m, m.form.Init() case orderSubmittedMsg: m.loading = false + m.pauseBroadcast = false if msg.err != nil { m.err = msg.err m.status = "Order submission failed." + if m.broadcastListening { + return m, listenForBroadcastsCmd(m.conn, m.reader) + } return m, nil } m.err = nil if msg.total > 0 { m.status = fmt.Sprintf("Order submitted. Total: $%.2f", msg.total) - if m.lastOrder != nil { - var itemName string - for _, it := range m.menu { - if it.ID == m.lastOrder.ItemID { - itemName = it.Name - break - } - } - if itemName != "" { - broadcastText := fmt.Sprintf("[order] %s ordered %d × %s ($%.2f)", - m.lastOrder.Name, m.lastOrder.Quantity, itemName, msg.total) - m.broadcasts = append(m.broadcasts, broadcastText) - if len(m.broadcasts) > 10 { - m.broadcasts = m.broadcasts[1:] - } - } + if !m.broadcastListening { + m.broadcastListening = true + return m, listenForBroadcastsCmd(m.conn, m.reader) } + return m, listenForBroadcastsCmd(m.conn, m.reader) } else if msg.ack != "" { m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack) } + if m.broadcastListening { + return m, listenForBroadcastsCmd(m.conn, m.reader) + } return m, nil case broadcastMsg: msgText := string(msg) - if strings.HasPrefix(msgText, "[order]") { + if msgText != "" && strings.HasPrefix(msgText, "[order]") { m.broadcasts = append(m.broadcasts, msgText) if len(m.broadcasts) > 10 { m.broadcasts = m.broadcasts[1:] } } + if m.pauseBroadcast { + time.Sleep(50 * time.Millisecond) + } return m, listenForBroadcastsCmd(m.conn, m.reader) - case statusMsg: - m.status = string(msg) + msgStr := string(msg) + m.status = msgStr + if strings.Contains(msgStr, "Connection closed") { + if m.conn != nil { + _ = m.conn.Close() + m.conn = nil + } + m.broadcastListening = false + m.reader = nil + } return m, nil case tea.KeyMsg: @@ -240,6 +240,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = m.conn.Close() m.conn = nil } + m.broadcastListening = false + m.reader = nil m.status = "Reconnecting..." return m, connectCmd(m.host) case "n": @@ -497,7 +499,7 @@ func connectCmd(addr string) tea.Cmd { // - server: single line JSON array: [{"id":"x","name":"..."}]\n func fetchMenuCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd { return func() tea.Msg { - if conn == nil { + if conn == nil || reader == nil { return menuLoadedMsg{err: errors.New("not connected")} } @@ -531,7 +533,7 @@ func fetchMenuCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd { // - server: a single line acknowledgement (freeform), e.g. "OK\n" func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd { return func() tea.Msg { - if conn == nil { + if conn == nil || reader == nil { return orderSubmittedMsg{err: errors.New("not connected")} } b, err := json.Marshal(ord) @@ -543,7 +545,9 @@ func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd { return orderSubmittedMsg{err: fmt.Errorf("send ORDER: %w", err)} } - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + time.Sleep(150 * time.Millisecond) + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) defer func() { _ = conn.SetReadDeadline(time.Time{}) }() line, err := reader.ReadString('\n') @@ -565,11 +569,23 @@ func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd { func listenForBroadcastsCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd { return func() tea.Msg { + defer func() { + if r := recover(); r != nil { + return + } + }() if conn == nil || reader == nil { return nil } + + _ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) line, err := reader.ReadString('\n') + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + return broadcastMsg("") + } return statusMsg(fmt.Sprintf("Connection closed: %v", err)) } return broadcastMsg(strings.TrimRight(line, "\r\n")) diff --git a/main.go.broken b/main.go.broken deleted file mode 100644 index 6af9b1d..0000000 --- a/main.go.broken +++ /dev/null @@ -1,592 +0,0 @@ -package main - -import ( - "bufio" - - "encoding/json" - "errors" - "flag" - "fmt" - "net" - "strconv" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" -) - -type menuItem struct { - ID string `json:"id"` - Name string `json:"name"` - Price float64 `json:"price"` -} - -// order represents the payload we submit back to the server. - -// messages used by Bubble Tea -type ( - connectedMsg struct{ conn net.Conn } - menuLoadedMsg struct { - items []menuItem - err error - } - orderSubmittedMsg struct { - ack string - total float64 - err error - } - broadcastMsg string - statusMsg string - serverLineMsg string -) - -type FormFields struct { - name string - itemID string - quantityStr string - confirm bool -} - -// model holds the TUI state. -type model struct { - host string - conn net.Conn - - title string - status string - loading bool - err error - lastOrder *order - broadcasts []string - - form *huh.Form - formFields *FormFields - menu []menuItem - name string - itemID string - quantityStr string - confirm bool - - width int - height int - - reader *bufio.Reader - waitingForMenu bool - waitingForOrderResp bool -} - -// initialModel creates a base model. -func initialModel(host string) model { - return model{ - host: host, - title: "Order Console", - formFields: &FormFields{}, - } -} - -func (m model) Init() tea.Cmd { - // Connect on startup - return connectCmd(m.host) -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - // If a form is active, delegate to it first. - if m.form != nil { - var cmd tea.Cmd - form, cmd := m.form.Update(msg) - - if f, ok := form.(*huh.Form); ok { - m.form = f - cmds = append(cmds, cmd) - } - - if m.form.State == huh.StateCompleted { - // Parse and submit order if confirmed. - qty, err := strconv.Atoi(strings.TrimSpace(m.formFields.quantityStr)) - if err != nil || qty <= 0 { - m.err = fmt.Errorf("invalid quantity: %v", m.formFields.quantityStr) - m.form = nil - return m, nil - } - ord := &order{ - Name: strings.TrimSpace(m.formFields.name), - ItemID: m.formFields.itemID, - Quantity: qty, - } - m.lastOrder = ord - m.form = nil - - if m.formFields.confirm { - if m.conn == nil { - m.status = "Not connected. Unable to submit order." - return m, nil - } - m.err = nil - m.loading = true - m.status = "Submitting order..." - m.waitingForOrderResp = true - return m, submitOrderCmd(m.conn, *ord) - } - m.status = "Order canceled." - return m, cmd - } - - if m.form.State == huh.StateAborted { - m.status = "Order form aborted." - m.form = nil - return m, cmd - } - - return m, tea.Batch(cmds...) - } - - switch msg := msg.(type) { - case connectedMsg: - m.conn = msg.conn - m.reader = bufio.NewReader(m.conn) - m.status = fmt.Sprintf("Connected to %s", m.host) - - _ = m.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - for i := 0; i < 2; i++ { - if _, err := m.reader.ReadString('\n'); err != nil { - break - } - } - _ = m.conn.SetReadDeadline(time.Time{}) - - return m, readServerLineCmd(m.reader) - - case menuLoadedMsg: - m.loading = false - if msg.err != nil { - m.err = msg.err - m.status = "Failed to load menu." - return m, nil - } - m.err = nil - m.menu = msg.items - m.status = "Menu loaded." - - var cmds []tea.Cmd - m.form = m.buildForm() - cmds = append(cmds, m.form.Init()) - - if !m.broadcastListening { - m.broadcastListening = true - cmds = append(cmds, listenForBroadcastsCmd(m.conn, m.reader)) - } - - return m, tea.Batch(cmds...) - - case orderSubmittedMsg: - m.loading = false - if msg.err != nil { - m.err = msg.err - m.status = "Order submission failed." - if m.conn != nil && m.reader != nil { - return m, listenForBroadcastsCmd(m.conn, m.reader) - } - return m, nil - } - m.err = nil - if msg.total > 0 { - m.status = fmt.Sprintf("Order submitted. Total: $%.2f", msg.total) - } else if msg.ack != "" { - m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack) - } - if m.conn != nil && m.reader != nil { - return m, listenForBroadcastsCmd(m.conn, m.reader) - } - return m, nil - - case broadcastMsg: - msgText := string(msg) - if strings.HasPrefix(msgText, "[order]") { - m.broadcasts = append(m.broadcasts, msgText) - if len(m.broadcasts) > 10 { - m.broadcasts = m.broadcasts[1:] - } - } - return m, listenForBroadcastsCmd(m.conn, m.reader) - - case statusMsg: - m.status = string(msg) - return m, nil - - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": - if m.conn != nil { - _ = m.conn.Close() - } - return m, tea.Quit - case "r": - // Reconnect - if m.conn != nil { - _ = m.conn.Close() - m.conn = nil - } - m.status = "Reconnecting..." - return m, connectCmd(m.host) - case "n": - if m.loading || m.form != nil { - return m, nil - } - if m.conn == nil { - m.status = "Not connected. Press 'r' to reconnect." - return m, nil - } - m.err = nil - if len(m.menu) > 0 { - m.form = m.buildForm() - return m, m.form.Init() - } - m.loading = true - m.status = "Loading menu..." - return m, fetchMenuCmd(m.conn, m.reader) - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - } - - return m, nil -} - -func (m model) renderHeader() string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) - hostStyle := lipgloss.NewStyle().Faint(true) - - title := titleStyle.Render(m.title) - host := hostStyle.Render(m.host) - - header := lipgloss.JoinVertical(lipgloss.Center, title, host) - return lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(header) -} - -func (m model) renderLeftColumn() string { - lines := []string{} - - if m.loading { - lines = append(lines, "Status: "+lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Render("Loading...")) - } else if m.status != "" { - lines = append(lines, "Status: "+m.status) - } - - if m.err != nil { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(fmt.Sprintf("Error: %v", m.err))) - } - - if m.lastOrder != nil { - lines = append(lines, "", lipgloss.NewStyle().Bold(true).Render("Last Order:")) - lines = append(lines, fmt.Sprintf(" Name: %s", m.lastOrder.Name)) - var label string - for _, it := range m.menu { - if it.ID == m.lastOrder.ItemID { - label = it.Name - break - } - } - if label != "" { - lines = append(lines, fmt.Sprintf(" Item: %s", label)) - } else { - lines = append(lines, fmt.Sprintf(" Item: %s", m.lastOrder.ItemID)) - } - lines = append(lines, fmt.Sprintf(" Quantity: %d", m.lastOrder.Quantity)) - } - - content := lipgloss.JoinVertical(lipgloss.Left, lines...) - return lipgloss.NewStyle(). - Width(m.width/2 - 2). - Height(m.height - 6). - Padding(1). - Render(content) -} - -func (m model) renderRightColumn() string { - lines := []string{} - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) - lines = append(lines, headerStyle.Render("Recent Orders:")) - lines = append(lines, "") - - if len(m.broadcasts) == 0 { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No orders yet...")) - } else { - bulletStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")) - nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true) - itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("117")) - priceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true) - - for _, b := range m.broadcasts { - msg := strings.TrimPrefix(b, "[order] ") - parts := strings.SplitN(msg, " ordered ", 2) - if len(parts) == 2 { - customer := parts[0] - orderDetails := parts[1] - - line := fmt.Sprintf("%s %s ordered %s", - bulletStyle.Render("•"), - nameStyle.Render(customer), - itemStyle.Render(orderDetails)) - - if idx := strings.Index(orderDetails, "($"); idx != -1 { - priceStart := idx - priceEnd := strings.Index(orderDetails[priceStart:], ")") - if priceEnd != -1 { - priceEnd += priceStart + 1 - beforePrice := orderDetails[:priceStart] - priceText := orderDetails[priceStart:priceEnd] - - line = fmt.Sprintf("%s %s ordered %s %s", - bulletStyle.Render("•"), - nameStyle.Render(customer), - itemStyle.Render(beforePrice), - priceStyle.Render(priceText)) - } - } - - lines = append(lines, line) - } - } - } - - content := lipgloss.JoinVertical(lipgloss.Left, lines...) - return lipgloss.NewStyle(). - Width(m.width/2 - 2). - Height(m.height - 6). - Padding(1). - Render(content) -} - -func (m model) renderFooter() string { - connStatus := "" - if m.conn != nil { - connStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("● Connected") - } else { - connStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("● Disconnected") - } - - controls := lipgloss.NewStyle().Faint(true).Render("n: New Order r: Reconnect q: Quit") - - leftSide := connStatus - rightSide := controls - - footer := lipgloss.JoinHorizontal(lipgloss.Top, - lipgloss.NewStyle().Width(m.width/2).Render(leftSide), - lipgloss.NewStyle().Width(m.width/2).Align(lipgloss.Right).Render(rightSide), - ) - - return lipgloss.NewStyle().Width(m.width).Render(footer) -} - -func (m model) View() string { - if m.form != nil { - return m.form.View() - } - - if m.width == 0 || m.height == 0 { - return "Loading..." - } - - header := m.renderHeader() - - leftCol := m.renderLeftColumn() - rightCol := m.renderRightColumn() - body := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) - - footer := m.renderFooter() - - return lipgloss.JoinVertical(lipgloss.Left, - header, - "", - body, - "", - footer, - ) -} - -// buildForm constructs the order form: Input (name) -> Select (menu) -> Input (qty) -> Confirm. -func (m *model) buildForm() *huh.Form { - opts := make([]huh.Option[string], 0, len(m.menu)) - for _, it := range m.menu { - opts = append(opts, huh.NewOption(fmt.Sprintf("%s - $%.2f", it.Name, it.Price), it.ID)) - } - - // Reset bound fields for a fresh form - m.formFields.name = "" - m.formFields.itemID = "" - m.formFields.quantityStr = "" - m.formFields.confirm = false - - f := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Your name"). - Prompt("> "). - Placeholder("Jane Doe"). - Value(&m.formFields.name). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return errors.New("name is required") - } - return nil - }), - huh.NewSelect[string](). - Title("Menu item"). - Options(opts...). - Value(&m.formFields.itemID). - Validate(func(v string) error { - if v == "" { - return errors.New("please select a menu item") - } - return nil - }), - huh.NewInput(). - Title("Quantity"). - Prompt("> "). - Placeholder("1"). - Value(&m.formFields.quantityStr). - Validate(func(s string) error { - n, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil || n <= 0 { - return errors.New("enter a positive integer") - } - return nil - }), - huh.NewConfirm(). - Title("Place order?"). - Affirmative("Yes"). - Negative("No"). - Value(&m.formFields.confirm), - ), - ) - - return f -} - -// connectCmd connects to the TCP server. -func connectCmd(addr string) tea.Cmd { - return func() tea.Msg { - conn, err := net.DialTimeout("tcp", addr, 3*time.Second) - if err != nil { - return statusMsg(fmt.Sprintf("Connect failed: %v", err)) - } - - return connectedMsg{conn: conn} - } -} - -// fetchMenuCmd asks the server for a menu via the TCP connection. -// Protocol (proposed): -// - client: "MENU\n" -// - server: single line JSON array: [{"id":"x","name":"..."}]\n -func fetchMenuCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd { - return func() tea.Msg { - if conn == nil { - return menuLoadedMsg{err: errors.New("not connected")} - } - - if _, err := fmt.Fprintln(conn, "MENU"); err != nil { - return menuLoadedMsg{err: fmt.Errorf("send MENU: %w", err)} - } - - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - defer func() { _ = conn.SetReadDeadline(time.Time{}) }() - - line, err := reader.ReadString('\n') - if err != nil { - return menuLoadedMsg{err: fmt.Errorf("read MENU: %w", err)} - } - line = strings.TrimRight(line, "\r\n") - if strings.HasPrefix(line, "[error]") { - return menuLoadedMsg{err: fmt.Errorf("server: %s", line)} - } - - var items []menuItem - if err := json.Unmarshal([]byte(line), &items); err != nil { - return menuLoadedMsg{err: fmt.Errorf("invalid menu JSON: %w", err)} - } - return menuLoadedMsg{items: items} - } -} - -// submitOrderCmd sends the order over TCP. -// Protocol (proposed): -// - client: "ORDER \n" -// - server: a single line acknowledgement (freeform), e.g. "OK\n" -func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd { - return func() tea.Msg { - if conn == nil { - return orderSubmittedMsg{err: errors.New("not connected")} - } - b, err := json.Marshal(ord) - if err != nil { - return orderSubmittedMsg{err: fmt.Errorf("marshal order: %w", err)} - } - - if _, err := fmt.Fprintf(conn, "ORDER %s\n", string(b)); err != nil { - return orderSubmittedMsg{err: fmt.Errorf("send ORDER: %w", err)} - } - - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - defer func() { _ = conn.SetReadDeadline(time.Time{}) }() - - line, err := reader.ReadString('\n') - if err != nil { - return orderSubmittedMsg{err: fmt.Errorf("read ORDER ack: %w", err)} - } - line = strings.TrimRight(line, "\r\n") - parts := strings.Split(line, "|") - ack := parts[0] - var total float64 - if len(parts) > 1 { - if t, err := strconv.ParseFloat(parts[1], 64); err == nil { - total = t - } - } - return orderSubmittedMsg{ack: ack, total: total} - } -} - -func listenForBroadcastsCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd { - return func() tea.Msg { - if conn == nil || reader == nil { - return nil - } - line, err := reader.ReadString('\n') - if err != nil { - return statusMsg(fmt.Sprintf("Connection closed: %v", err)) - } - return broadcastMsg(strings.TrimRight(line, "\r\n")) - } -} - -func main() { - var ( - host string - serverOnly bool - ) - flag.StringVar(&host, "host", "localhost:9000", "host:port to connect to or bind the server on") - flag.BoolVar(&serverOnly, "server", false, "run only the server") - flag.Parse() - - // If requested, start the TCP server (chat server as-is). - if serverOnly { - if err := startTCPServer(host); err != nil { - fmt.Println("Server error:", err) - } - return - } - - // Client TUI - m := initialModel(host) - p := tea.NewProgram(m, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Println("error:", err) - } -} diff --git a/server.go b/server.go index 88e3bdc..868283f 100644 --- a/server.go +++ b/server.go @@ -205,7 +205,7 @@ func handleConn(h *Hub, c net.Conn) { total := float64(ord.Quantity) * chosen.Price h.msgCh <- broadcast{ - text: fmt.Sprintf("[order] %s (%s) ordered %d × %s ($%.2f)", username, id, ord.Quantity, chosen.Name, total), + text: fmt.Sprintf("[order] %s ordered %d × %s ($%.2f)", ord.Name, ord.Quantity, chosen.Name, total), } fmt.Fprintf(c, "OK|%.2f\n", total)