- Add lipgloss.RoundedBorder to left/right columns for visual separation - Render the huh form within the left column box instead of full-screen takeover - Keep right column (recent orders) visible while filling the form - Integrate bubbles/key and set a custom Huh keymap: - Quit binding: ctrl+c/esc with help text - Apply base theme to the form via WithTheme(huh.ThemeBase()) - Minor layout tweaks to ensure consistent sizing and padding |
||
|---|---|---|
| .github/workflows | ||
| .gitignore | ||
| AGENTS.md | ||
| build.sh | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| README.md | ||
| server.go | ||
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
- TCP Server (
server.go) - Multi-client socket server with broadcast capability - TUI Client (
main.go) - Interactive terminal client using TCP sockets - Protocol - Custom text-based protocol over TCP
Socket Programming Concepts Demonstrated
1. TCP Server Socket Creation
Location: server.go:252-256
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.Listenerthat can accept incoming connections
2. Accepting Client Connections
Location: server.go:261-268
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
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.Conninterface for bidirectional communication
4. Buffered Reading from Socket
Location: main.go:148-150
case connectedMsg:
m.conn = msg.conn
m.reader = bufio.NewReader(m.conn)
Why Buffered Reading?
- Raw socket reads are byte-level and inefficient
bufio.Readerprovides buffering and line-reading capabilities- Critical: Single
bufio.Readerinstance per connection prevents data corruption - Multiple readers on the same socket would compete for bytes
5. Reading Greeting Messages
Location: main.go:153-159
_ = 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
// 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
// Server responses
fmt.Fprintln(c, string(b)) // MENU response
fmt.Fprintf(c, "OK|%.2f\n", total) // ORDER response
fmt.Fprintln()andfmt.Fprintf()write to anyio.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
// 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.ErrorwithTimeout() == true
8. Handling Timeout Errors
Location: main.go:585-589
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
// 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
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
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
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
deferensures cleanup when connection closes- Uses
bufio.Scannerfor line-by-line reading - Handles multiple protocol commands:
MENU,ORDER,/name,/quit
12. Connection Lifecycle
Server Side:
ln.Accept()- Accept incoming connection (server.go:262)h.joinCh <- c- Register connection in hub (server.go:117)- Send greeting messages (
server.go:131-132) - Process commands in loop (
server.go:141-240) h.leaveCh <- c- Unregister and close (server.go:116)
Client Side:
net.DialTimeout()- Establish connection (main.go:487)- Create
bufio.Reader(main.go:150) - Consume greeting messages (
main.go:153-159) - Send protocol commands (
MENU,ORDER) - Listen for broadcasts (
main.go:570-593) 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 <json>\n - Location:
main.go:544 - Server handler:
server.go:160-213 - Response:
OK|<total>\n
Example:
Client: ORDER {"name":"Alice","itemId":"latte","quantity":2}
Server: OK|9.00
Server → Client (Broadcasts)
1. Order Broadcast
- Format:
[order] <name> ordered <qty> × <item> ($<total>)\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] <username> (<id>)\nor[leave] <username> (<id>)\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 <json>\n"
├─ Sleep 150ms (coordination)
├─ SetReadDeadline(5s)
└─ Read: "OK|<total>\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 <json>\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|<total>\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
pauseBroadcastflag 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.Readercreated 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:
-
TCP Client-Server Architecture
- Server:
net.Listen(),Accept(), per-connection goroutines - Client:
net.DialTimeout(), shared connection
- Server:
-
Custom Text Protocol
- Newline-delimited messages
- Request-response (MENU, ORDER)
- Server-initiated broadcasts
-
Concurrent Socket Communication
- Multiple clients handled concurrently (server)
- Synchronous + asynchronous reads on single socket (client)
-
Socket I/O Techniques
- Buffered reading with
bufio.Reader - Read deadlines for timeouts
- Formatted writing with
fmt.Fprintf()
- Buffered reading with
-
Broadcast Pattern
- Hub with channels for thread-safe fan-out
- Write to multiple sockets from single goroutine
-
Real-World Challenges
- Read coordination between sync/async operations
- Connection lifecycle management
- Error handling and recovery
Running the Application
Start Server:
go run . -server -host localhost:9000
Start Client(s):
go run . -host localhost:9000
Client Controls:
n- New order (loads menu if needed)r- Reconnectq- Quit
File Structure
clink/
├── main.go # TUI client with socket operations
├── server.go # TCP server with hub pattern
└── README.md # This documentation