Overview
Sockets provide an abstraction for network communication between processes, either on the same machine or across networks. A socket serves as an endpoint for sending and receiving data, identified by an IP address and port number combination. The socket API originated in BSD Unix in the early 1980s and has become the standard interface for network programming across operating systems.
Network communication through sockets follows a client-server model. The server creates a socket, binds it to an address and port, and listens for incoming connections. Clients create sockets and initiate connections to server addresses. Once connected, both sides exchange data through read and write operations on the socket file descriptors.
Socket programming operates at the transport layer of the network stack, typically using TCP (Transmission Control Protocol) or UDP (User Datagram Protocol). TCP provides reliable, ordered, connection-oriented communication with built-in error checking and flow control. UDP offers connectionless, best-effort delivery with lower overhead but no guarantees of packet delivery or ordering.
# Basic TCP server socket
require 'socket'
server = TCPServer.new(8080)
puts "Server listening on port 8080"
client = server.accept
puts "Client connected from #{client.peeraddr[3]}"
data = client.gets
client.puts "Echo: #{data}"
client.close
The socket abstraction hides network complexity behind familiar file I/O operations. Applications read from and write to sockets using similar methods as file handling, while the operating system manages packet transmission, routing, and protocol implementation. This abstraction enables portable network code across different platforms and network types.
Key Principles
Socket communication operates on several fundamental principles that determine how data flows between networked processes. Understanding these principles is essential for designing reliable and efficient network applications.
Address Families and Domains
Sockets belong to address families that define the format of addresses and the communication domain. The AF_INET family uses IPv4 addresses (32-bit addresses like 192.168.1.1). AF_INET6 uses IPv6 addresses (128-bit addresses like 2001:0db8::1). AF_UNIX (also called AF_LOCAL) uses filesystem paths for inter-process communication on the same machine, offering higher performance than network sockets for local communication.
Each socket type serves different communication needs. Stream sockets (SOCK_STREAM) provide reliable, bidirectional, connection-oriented byte streams, typically using TCP. Datagram sockets (SOCK_DGRAM) send discrete messages without establishing connections, typically using UDP. Raw sockets (SOCK_RAW) access lower-level protocols and require elevated privileges.
Connection-Oriented Communication
TCP sockets establish connections through a three-way handshake before data transmission. The client sends a SYN packet, the server responds with SYN-ACK, and the client completes the handshake with an ACK. This process ensures both sides are ready for communication and agree on initial sequence numbers.
The connection lifecycle for TCP follows a specific sequence. The server creates a socket with socket(), binds it to an address with bind(), marks it as a listening socket with listen(), and waits for connections with accept(). Each call to accept() blocks until a client connects and returns a new socket for that connection. The server continues accepting new connections while handling existing ones, typically using threads or multiplexing.
Clients create a socket and call connect() with the server's address. The connect() call blocks until the connection completes or times out. After successful connection, both client and server use the same read/write operations to exchange data. Connection teardown uses close() or shutdown(), triggering a four-way FIN/ACK sequence.
Connectionless Communication
UDP sockets skip connection establishment and send datagrams directly to destination addresses. Each datagram includes source and destination addresses, allowing communication with multiple peers through a single socket. Applications must handle packet loss, duplication, and reordering themselves.
The recvfrom() and sendto() methods handle datagram communication. sendto() specifies the destination address with each transmission. recvfrom() returns both the data and the sender's address, enabling response routing. UDP sockets can also use connect() to associate with a specific remote address, allowing use of read() and write() instead of sendto() and recvfrom().
Socket States and Blocking Behavior
Sockets operate in blocking or non-blocking mode. Blocking sockets cause operations like accept(), recv(), and connect() to suspend the calling thread until data arrives or the operation completes. This simplifies programming but limits concurrency. Non-blocking sockets return immediately with an error code if the operation cannot complete instantly.
Socket buffers store data temporarily during transmission. The send buffer holds outbound data until transmission, while the receive buffer stores incoming data before application retrieval. Buffer sizes affect performance and memory usage. When buffers fill, send operations block (in blocking mode) or return errors (in non-blocking mode).
Multiplexing and Concurrency
Single-threaded servers handle multiple connections through multiplexing mechanisms like select() or poll(). These functions monitor multiple sockets and report which ones have data available or can accept writes without blocking. The application processes ready sockets in a loop, providing concurrency without threads.
Multiplexing supports three event types: readable (data available or connection pending), writable (send buffer has space), and exceptional (out-of-band data or errors). Applications create sets of sockets to monitor and call select() or poll(), which blocks until events occur on any monitored socket. The function returns a modified set indicating ready sockets.
Error Handling and Reliability
Network operations fail for various reasons: connection refused, host unreachable, timeout, network failure, or buffer overflow. Applications must handle these errors appropriately. Connection errors typically require retry logic with exponential backoff. Timeout errors may indicate network congestion or unresponsive peers.
TCP handles reliability at the transport layer through acknowledgments, retransmission, and flow control. Applications see an abstraction of reliable, ordered byte streams. However, TCP cannot prevent application-level issues like incomplete messages if connections close mid-transmission. Applications implement framing protocols to detect message boundaries and handle partial transmissions.
Ruby Implementation
Ruby provides socket programming through the Socket class and higher-level wrappers in the standard library. The Socket class exposes low-level socket operations matching BSD sockets, while classes like TCPServer, TCPSocket, UDPSocket, and UNIXSocket offer cleaner interfaces for common patterns.
TCP Server Implementation
TCPServer creates listening sockets that accept client connections. The constructor takes a port number and optional bind address. The accept method blocks until a connection arrives and returns a TCPSocket for that client.
require 'socket'
server = TCPServer.new(9000)
loop do
client = server.accept
Thread.new(client) do |connection|
begin
request = connection.gets
response = process_request(request)
connection.puts response
ensure
connection.close
end
end
end
This pattern creates a new thread for each connection, allowing concurrent client handling. The ensure block guarantees socket closure even if errors occur. Thread-per-connection scales to moderate loads but faces resource limits with many connections.
TCP Client Implementation
TCPSocket connects to remote servers. The constructor takes the remote host and port, automatically performing DNS resolution and connection establishment. Connection errors raise Errno exceptions.
require 'socket'
def fetch_http(host, path)
socket = TCPSocket.new(host, 80)
socket.print "GET #{path} HTTP/1.1\r\n"
socket.print "Host: #{host}\r\n"
socket.print "Connection: close\r\n"
socket.print "\r\n"
response = socket.read
socket.close
response
end
This example implements a basic HTTP client. The print methods send request headers, and read retrieves the complete response. Connection closure happens automatically when the server closes its end, allowing read to return all data.
UDP Socket Implementation
UDPSocket handles datagram communication. Unlike TCP, no connection setup occurs. The send method transmits datagrams to specified addresses, and recvfrom receives datagrams along with sender information.
require 'socket'
# UDP server
server = UDPSocket.new
server.bind('0.0.0.0', 5000)
loop do
data, sender = server.recvfrom(1024)
client_ip = sender[3]
client_port = sender[1]
response = "Received: #{data}"
server.send(response, 0, client_ip, client_port)
end
The recvfrom method's second parameter specifies maximum datagram size. The method returns an array containing the data and sender information (port, address family, IP address, hostname). The send method's second parameter specifies flags (typically 0).
Socket Options and Configuration
Socket options control socket behavior at various protocol levels. Common options include timeouts, buffer sizes, address reuse, and keepalive settings. Ruby accesses these through setsockopt and getsockopt methods.
require 'socket'
server = TCPServer.new(8080)
# Reuse address immediately after close
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
# Set receive timeout to 5 seconds
timeout = [5, 0].pack('l_2')
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeout)
# Configure TCP keepalive
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, 1)
server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 60)
server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 3)
SO_REUSEADDR allows binding to addresses in TIME_WAIT state, useful during development when servers restart frequently. SO_RCVTIMEO and SO_SNDTIMEO prevent indefinite blocking on I/O operations. Keepalive options detect dead connections by sending periodic probes.
Non-Blocking I/O
Ruby supports non-blocking socket operations through fcntl or the Socket::NONBLOCK flag. Non-blocking sockets return immediately from I/O operations, raising IO::WaitReadable or IO::WaitWritable when operations would block.
require 'socket'
server = TCPServer.new(8080)
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
# Set non-blocking mode
server.fcntl(Fcntl::F_SETFL, Fcntl::O_NONBLOCK)
loop do
begin
client = server.accept_nonblock
handle_client(client)
rescue IO::WaitReadable
# No connection available, do other work
IO.select([server])
retry
end
end
This pattern attempts non-blocking accept and rescues WaitReadable when no connection is pending. IO.select blocks until the server socket becomes readable, indicating a pending connection. The retry restarts the accept attempt.
Socket Multiplexing with IO.select
IO.select monitors multiple sockets simultaneously, returning when any become ready for I/O. The method accepts arrays of sockets to monitor for reading, writing, or errors, plus an optional timeout.
require 'socket'
server = TCPServer.new(8080)
clients = []
loop do
readable, writable, errors = IO.select([server] + clients, [], [], 5)
next unless readable
readable.each do |socket|
if socket == server
# New connection
clients << server.accept
else
# Existing client has data
begin
data = socket.read_nonblock(1024)
socket.write_nonblock("Echo: #{data}")
rescue EOFError
# Client disconnected
clients.delete(socket)
socket.close
end
end
end
end
This event loop handles new connections and client data through a single thread. The select call blocks for up to 5 seconds or until sockets become ready. The readable array contains sockets with available data or pending connections.
Unix Domain Sockets
UNIXSocket and UNIXServer provide inter-process communication through filesystem paths. These sockets offer better performance than TCP sockets for local communication and inherit filesystem permissions.
require 'socket'
# Server
File.delete('/tmp/app.sock') if File.exist?('/tmp/app.sock')
server = UNIXServer.new('/tmp/app.sock')
Thread.new do
loop do
client = server.accept
data = client.recv(1024)
client.send("Processed: #{data}", 0)
client.close
end
end
# Client
socket = UNIXSocket.new('/tmp/app.sock')
socket.send('Hello', 0)
response = socket.recv(1024)
socket.close
Unix sockets create files at the specified path. Applications must remove existing socket files before binding. The socket file persists after the server closes, requiring cleanup during shutdown. Permissions control which processes can connect.
Implementation Approaches
Socket programming supports various architectural patterns depending on concurrency requirements, performance goals, and application complexity. The choice of approach affects scalability, resource usage, and code complexity.
Single-Threaded Sequential Processing
The simplest approach handles one connection at a time. The server accepts a connection, processes the request, sends a response, closes the connection, and loops to accept the next connection. This pattern works for low-traffic services or applications where connection setup overhead dominates processing time.
require 'socket'
server = TCPServer.new(8080)
loop do
client = server.accept
# Handle request completely before accepting next connection
request = client.read
response = process_request(request)
client.write(response)
client.close
end
Sequential processing offers simplicity and predictability. No concurrency management is needed, eliminating race conditions and synchronization issues. However, clients must wait for previous requests to complete, causing poor responsiveness under load. This approach suits scenarios like simple configuration servers or development environments.
Thread-Per-Connection Model
Creating a thread for each connection enables concurrent client handling. The main thread accepts connections and spawns worker threads to handle requests. Threads share process memory, allowing easy data sharing but requiring synchronization for shared state.
require 'socket'
server = TCPServer.new(8080)
loop do
client = server.accept
Thread.new(client) do |connection|
begin
while data = connection.gets
response = process_data(data)
connection.puts(response)
end
rescue => e
puts "Error handling client: #{e.message}"
ensure
connection.close
end
end
end
Thread-per-connection scales to hundreds of concurrent connections on modern systems. Ruby's Global Interpreter Lock (GIL) prevents true parallel execution of Ruby code, but threads still benefit I/O-bound workloads. The operating system switches between threads during I/O waits, maintaining responsiveness. Thread creation overhead and memory usage limit scalability to thousands of connections.
Process-Per-Connection Model
Forking processes for each connection provides isolation between clients. Unlike threads, processes have separate memory spaces, preventing one connection from affecting others. Process isolation improves security and stability but increases resource usage.
require 'socket'
server = TCPServer.new(8080)
loop do
client = server.accept
pid = fork do
# Child process handles connection
server.close # Close listening socket in child
handle_client(client)
client.close
exit
end
# Parent process continues accepting
client.close # Close client socket in parent
Process.detach(pid) # Prevent zombie processes
end
The parent process retains the listening socket after forking, while children close it to prevent descriptor leaks. Similarly, the parent closes client sockets it doesn't use. Process.detach prevents zombie processes by automatically reaping terminated children. This model supports preforking, where processes are created before connections arrive.
Reactor Pattern with IO.select
The reactor pattern multiplexes multiple connections through event-driven programming. A single thread monitors all sockets, processing I/O when sockets become ready. This approach eliminates threading overhead and scales to thousands of connections.
require 'socket'
class Reactor
def initialize(port)
@server = TCPServer.new(port)
@clients = {}
@running = true
end
def run
while @running
readable, writable, errors = IO.select(
[@server] + @clients.keys,
@clients.select { |k, v| v[:buffer].any? }.keys,
[],
1
)
readable&.each { |socket| handle_readable(socket) }
writable&.each { |socket| handle_writable(socket) }
end
end
private
def handle_readable(socket)
if socket == @server
accept_connection
else
receive_data(socket)
end
end
def accept_connection
client = @server.accept
client.fcntl(Fcntl::F_SETFL, Fcntl::O_NONBLOCK)
@clients[client] = { buffer: [] }
end
def receive_data(socket)
data = socket.read_nonblock(4096)
response = process_data(data)
@clients[socket][:buffer] << response
rescue EOFError, Errno::ECONNRESET
@clients.delete(socket)
socket.close
end
def handle_writable(socket)
data = @clients[socket][:buffer].shift
socket.write_nonblock(data)
rescue Errno::EPIPE
@clients.delete(socket)
socket.close
end
end
The reactor maintains state for each connection, including pending data to write. IO.select monitors readable sockets (including the server for new connections) and sockets with pending writes. The pattern processes events synchronously, preventing one slow handler from blocking others by keeping handlers fast.
Thread Pool Architecture
Thread pools limit the number of concurrent threads, balancing concurrency and resource usage. A fixed-size pool of worker threads processes connections from a queue. The main thread accepts connections and enqueues them for workers.
require 'socket'
require 'thread'
class ThreadPoolServer
def initialize(port, pool_size)
@server = TCPServer.new(port)
@queue = Queue.new
@workers = pool_size.times.map { spawn_worker }
end
def run
loop do
client = @server.accept
@queue.push(client)
end
end
private
def spawn_worker
Thread.new do
loop do
client = @queue.pop
handle_client(client)
client.close
rescue => e
puts "Worker error: #{e.message}"
end
end
end
def handle_client(client)
request = client.read
response = process_request(request)
client.write(response)
end
end
Thread pools prevent unbounded thread creation while maintaining concurrency. Queue depth indicates system load; growing queues signal capacity issues. Pool size tuning balances CPU usage and responsiveness. Ruby's Queue class provides thread-safe enqueueing and dequeueing with built-in blocking when empty.
Event Machine and Async I/O Libraries
External libraries like EventMachine provide sophisticated event-driven architectures. These libraries handle socket multiplexing, event dispatching, and connection management, allowing focus on application logic.
EventMachine uses callbacks for connection events (receive data, connection closed, etc.). The library manages the event loop, calling application code when events occur. This inverts control compared to traditional blocking I/O, where application code explicitly waits for data.
The callback-based approach requires different thinking about program flow. Applications cannot block waiting for responses; they must register callbacks that execute when responses arrive. This leads to callback chains that can become complex in applications with multiple asynchronous operations.
Practical Examples
Socket programming implementations demonstrate various communication patterns and techniques applicable to real-world scenarios. These examples progress from basic protocols to more complex interactions.
Echo Server with Error Handling
An echo server demonstrates fundamental socket operations with proper error handling and resource management. The server receives data and sends it back unchanged.
require 'socket'
class EchoServer
def initialize(port)
@server = TCPServer.new(port)
@server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
puts "Echo server listening on port #{port}"
end
def run
loop do
begin
client = @server.accept
Thread.new(client) { |conn| handle_client(conn) }
rescue Interrupt
puts "\nShutting down..."
break
rescue => e
puts "Error accepting connection: #{e.message}"
end
end
ensure
@server.close if @server
end
private
def handle_client(client)
client_addr = client.peeraddr[3]
puts "Client connected: #{client_addr}"
while line = client.gets
client.puts line
puts "#{client_addr}: #{line.chomp}"
end
rescue Errno::ECONNRESET, Errno::EPIPE, IOError
puts "Client #{client_addr} disconnected"
ensure
client.close
end
end
server = EchoServer.new(3000)
server.run
This implementation handles interrupts gracefully for shutdown, catches connection errors without crashing, and ensures socket cleanup. The peeraddr method retrieves client connection information for logging. Thread-per-connection allows multiple simultaneous clients.
HTTP Request Client
Creating HTTP requests through raw sockets demonstrates protocol implementation and request/response handling. This client supports basic GET requests with header parsing.
require 'socket'
require 'uri'
class SimpleHTTPClient
def get(url)
uri = URI.parse(url)
socket = TCPSocket.new(uri.host, uri.port || 80)
socket.setsockopt(Socket::SOL_TCP, Socket::TCP_NODELAY, 1)
# Build HTTP request
request = build_request(uri)
socket.print request
# Read response
response = read_response(socket)
socket.close
response
end
private
def build_request(uri)
path = uri.path.empty? ? '/' : uri.path
path += "?#{uri.query}" if uri.query
[
"GET #{path} HTTP/1.1",
"Host: #{uri.host}",
"User-Agent: SimpleHTTPClient/1.0",
"Accept: */*",
"Connection: close",
"",
""
].join("\r\n")
end
def read_response(socket)
# Read status line
status_line = socket.gets
status_code = status_line.split[1].to_i
# Read headers
headers = {}
while line = socket.gets
break if line.strip.empty?
key, value = line.split(':', 2)
headers[key.strip.downcase] = value.strip
end
# Read body
body = if headers['content-length']
socket.read(headers['content-length'].to_i)
else
socket.read
end
{ status: status_code, headers: headers, body: body }
end
end
client = SimpleHTTPClient.new
response = client.get('http://example.com/')
puts "Status: #{response[:status]}"
puts response[:body]
TCP_NODELAY disables Nagle's algorithm for immediate packet transmission. The request follows HTTP/1.1 format with required headers. Response parsing handles both content-length and connection-close body delimiting. This demonstrates protocol-level socket programming beyond library abstractions.
Chat Server with Broadcasting
A chat server illustrates connection management and message broadcasting. Multiple clients connect, and messages from any client broadcast to all others.
require 'socket'
class ChatServer
def initialize(port)
@server = TCPServer.new(port)
@clients = []
@mutex = Mutex.new
puts "Chat server started on port #{port}"
end
def run
loop do
Thread.new(@server.accept) do |client|
handle_client(client)
end
end
end
private
def handle_client(client)
nickname = client.gets.chomp
@mutex.synchronize do
@clients << { socket: client, nickname: nickname }
broadcast("#{nickname} joined the chat", client)
end
while message = client.gets
@mutex.synchronize do
broadcast("#{nickname}: #{message}", client)
end
end
rescue
# Client disconnected
ensure
@mutex.synchronize do
@clients.delete_if { |c| c[:socket] == client }
broadcast("#{nickname} left the chat", client) if nickname
end
client.close
end
def broadcast(message, sender)
@clients.each do |client|
next if client[:socket] == sender
begin
client[:socket].puts message
rescue
# Remove dead connections
@clients.delete(client)
end
end
end
end
server = ChatServer.new(5000)
server.run
Mutual exclusion protects the client list from concurrent modification. Broadcasting sends messages to all connected clients except the sender. Error handling during broadcast removes dead connections without affecting others. The server expects clients to send nicknames as the first message.
UDP Time Server
A UDP-based time server demonstrates datagram communication. Clients send requests and receive timestamps without connection establishment.
require 'socket'
class TimeServer
def initialize(port)
@socket = UDPSocket.new
@socket.bind('0.0.0.0', port)
puts "Time server listening on UDP port #{port}"
end
def run
loop do
data, sender = @socket.recvfrom(1024)
client_ip = sender[3]
client_port = sender[1]
puts "Request from #{client_ip}:#{client_port}"
response = case data.strip
when 'TIME'
Time.now.to_s
when 'TIMESTAMP'
Time.now.to_i.to_s
when 'ISO8601'
Time.now.iso8601
else
"Unknown command. Use TIME, TIMESTAMP, or ISO8601"
end
@socket.send(response, 0, client_ip, client_port)
end
end
end
# Server
server = TimeServer.new(3000)
server.run
# Client
client = UDPSocket.new
client.send('TIME', 0, 'localhost', 3000)
response, _ = client.recvfrom(1024)
puts "Server time: #{response}"
client.close
UDP communication requires no connection setup. Each datagram stands alone; the server processes requests independently. The recvfrom return value includes sender information for responses. UDP suits scenarios where occasional packet loss is acceptable and low latency matters more than reliability.
Port Scanner
A port scanner demonstrates concurrent connection attempts with timeout handling. The scanner tests multiple ports simultaneously to detect open services.
require 'socket'
require 'timeout'
class PortScanner
def initialize(host, timeout: 1)
@host = host
@timeout = timeout
end
def scan(ports)
threads = ports.map do |port|
Thread.new { check_port(port) }
end
results = threads.map(&:value).compact
results.sort_by { |r| r[:port] }
end
private
def check_port(port)
Timeout.timeout(@timeout) do
socket = TCPSocket.new(@host, port)
socket.close
{ port: port, state: :open }
end
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
{ port: port, state: :closed }
rescue Timeout::Error
{ port: port, state: :filtered }
rescue => e
{ port: port, state: :error, message: e.message }
end
end
scanner = PortScanner.new('localhost')
results = scanner.scan(20..100)
results.each do |result|
puts "Port #{result[:port]}: #{result[:state]}"
end
Concurrent scanning improves speed dramatically compared to sequential scanning. Timeout prevents hanging on filtered ports. Different exceptions indicate port states: ECONNREFUSED means the port is closed, timeout suggests filtering by a firewall. This pattern applies to any scenario requiring multiple connection tests.
Error Handling & Edge Cases
Network programming encounters numerous error conditions requiring careful handling. Robust socket applications anticipate failures, implement appropriate recovery strategies, and maintain consistency under adverse conditions.
Connection Failures
Connection establishment fails for various reasons: host unreachable, connection refused, timeout, or network unreachable. Each error suggests different underlying problems and recovery strategies.
require 'socket'
def robust_connect(host, port, max_attempts: 3, timeout: 5)
attempt = 0
begin
attempt += 1
Timeout.timeout(timeout) do
socket = TCPSocket.new(host, port)
return socket
end
rescue Errno::ECONNREFUSED
# Service not running, retry won't help immediately
raise "Connection refused by #{host}:#{port}"
rescue Errno::EHOSTUNREACH, Errno::ENETUNREACH
# Network problem, retry might succeed
if attempt < max_attempts
sleep(2 ** attempt) # Exponential backoff
retry
else
raise "Host unreachable after #{max_attempts} attempts"
end
rescue Timeout::Error
# Connection hanging, possibly network congestion
if attempt < max_attempts
retry
else
raise "Connection timeout after #{max_attempts} attempts"
end
rescue SocketError => e
# DNS resolution failure
raise "Cannot resolve hostname: #{e.message}"
end
end
ECONNREFUSED indicates the target port has no listening service. Retrying immediately rarely helps; applications should wait or alert administrators. EHOSTUNREACH suggests routing problems that might resolve after delays. Exponential backoff prevents overwhelming recovering networks. SocketError covers DNS failures requiring different handling than network errors.
Partial Reads and Writes
TCP provides a byte stream without message boundaries. Applications cannot assume read operations return complete messages or that write operations send all data immediately.
def read_exact(socket, length)
data = String.new
while data.bytesize < length
chunk = socket.read(length - data.bytesize)
if chunk.nil? || chunk.empty?
raise EOFError, "Connection closed after #{data.bytesize} of #{length} bytes"
end
data << chunk
end
data
end
def write_all(socket, data)
written = 0
while written < data.bytesize
begin
n = socket.write_nonblock(data.byteslice(written..-1))
written += n
rescue IO::WaitWritable
# Send buffer full, wait for space
IO.select(nil, [socket])
retry
end
end
written
end
# Message framing with length prefix
def send_message(socket, message)
length = [message.bytesize].pack('N') # 4-byte network byte order
write_all(socket, length)
write_all(socket, message)
end
def receive_message(socket)
length_bytes = read_exact(socket, 4)
length = length_bytes.unpack1('N')
read_exact(socket, length)
end
Length-prefixed framing ensures complete message transmission. The read_exact method loops until receiving the specified bytes, detecting premature closure. write_all handles partial writes from full send buffers, using IO.select to wait for buffer space. Applications must implement similar logic for any binary protocol.
Timeout Management
Timeouts prevent indefinite blocking on network operations. Ruby supports timeouts through socket options, IO.select, or the Timeout module.
require 'socket'
require 'timeout'
class TimeoutSocket
def initialize(host, port, timeout: 10)
@socket = nil
@timeout = timeout
# Connection timeout via Timeout module
Timeout.timeout(@timeout) do
@socket = TCPSocket.new(host, port)
end
# Set socket-level timeouts
timeval = [@timeout, 0].pack('l_2')
@socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeval)
@socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, timeval)
end
def read_with_timeout(length)
Timeout.timeout(@timeout) do
@socket.read(length)
end
rescue Timeout::Error
raise "Read timeout after #{@timeout} seconds"
end
def write_with_timeout(data)
Timeout.timeout(@timeout) do
@socket.write(data)
end
rescue Timeout::Error
raise "Write timeout after #{@timeout} seconds"
end
def close
@socket.close if @socket
end
end
Socket-level timeouts (SO_RCVTIMEO, SO_SNDTIMEO) apply to individual I/O operations. The Timeout module provides higher-level operation timeouts. Combining both strategies ensures protection against hanging operations at multiple levels. Timeout durations should account for expected network latency and data volumes.
Buffer Overflow Prevention
Fixed-size buffers prevent unbounded memory growth when receiving large amounts of data. Applications must limit read sizes and implement resource constraints.
class BoundedReceiver
MAX_MESSAGE_SIZE = 1024 * 1024 # 1 MB limit
def initialize(socket)
@socket = socket
@buffer = String.new
end
def receive_limited
while @buffer.bytesize < MAX_MESSAGE_SIZE
chunk = @socket.read_nonblock(4096)
@buffer << chunk
# Check for message terminator
if message = extract_message
return message
end
rescue IO::WaitReadable
IO.select([@socket])
retry
end
raise "Message exceeds maximum size of #{MAX_MESSAGE_SIZE} bytes"
end
private
def extract_message
# Example: newline-terminated messages
if index = @buffer.index("\n")
message = @buffer.slice!(0, index + 1)
message.chomp
end
end
end
The bounded receiver accumulates data up to a maximum size while searching for message boundaries. Applications should validate input sizes before processing to prevent denial of service through memory exhaustion. Rate limiting and connection quotas provide additional protection against resource abuse.
Signal Handling and Cleanup
Servers must handle signals gracefully, closing sockets and cleaning up resources before termination. Improper shutdown can leave sockets in TIME_WAIT state or orphan child processes.
require 'socket'
class GracefulServer
def initialize(port)
@server = TCPServer.new(port)
@running = true
@threads = []
setup_signal_handlers
end
def run
while @running
begin
client = @server.accept_nonblock
@threads << Thread.new(client) { |c| handle_client(c) }
rescue IO::WaitReadable
IO.select([@server], nil, nil, 1)
end
# Clean up finished threads
@threads.reject!(&:alive?)
end
shutdown
end
private
def setup_signal_handlers
['INT', 'TERM'].each do |signal|
Signal.trap(signal) do
puts "\nReceived #{signal}, shutting down..."
@running = false
end
end
end
def shutdown
puts "Closing server socket..."
@server.close
puts "Waiting for #{@threads.count} client threads..."
@threads.each { |t| t.join(5) } # Wait up to 5 seconds per thread
puts "Shutdown complete"
end
def handle_client(client)
# Handle client connection
ensure
client.close
end
end
Signal handlers set flags rather than directly closing resources, allowing the main loop to shut down cleanly. The server waits for active connections to complete with a timeout. Non-blocking accept with IO.select allows checking the running flag periodically. This pattern ensures proper resource cleanup and prevents connection interruption.
Security Implications
Socket programming exposes applications to various security threats. Network-facing services must implement defensive measures against attacks, validate all input, and protect sensitive data during transmission.
Input Validation and Injection
Network input arrives from untrusted sources and requires rigorous validation. Applications must sanitize data before using it in commands, queries, or system operations to prevent injection attacks.
require 'socket'
class SecureCommandServer
ALLOWED_COMMANDS = ['STATUS', 'INFO', 'HELP'].freeze
MAX_INPUT_LENGTH = 256
def handle_client(client)
while line = client.gets
command = validate_input(line)
response = case command
when 'STATUS'
get_system_status
when 'INFO'
get_system_info
when 'HELP'
get_help_text
else
"Unknown command"
end
client.puts response
end
rescue SecurityError => e
client.puts "Error: #{e.message}"
client.close
end
private
def validate_input(input)
# Length check
if input.bytesize > MAX_INPUT_LENGTH
raise SecurityError, "Input exceeds maximum length"
end
# Character whitelist
unless input =~ /\A[A-Z]+\z/
raise SecurityError, "Input contains invalid characters"
end
# Command whitelist
command = input.strip.upcase
unless ALLOWED_COMMANDS.include?(command)
raise SecurityError, "Command not permitted"
end
command
end
end
Whitelist validation prevents injection by accepting only known-safe commands and characters. Length limits prevent buffer overflow and denial of service. Never trust client input or use it directly in system commands, file paths, or database queries. Sanitization alone is insufficient; use parameterized interfaces or prepared statements when available.
TLS/SSL Encryption
Sensitive data transmitted over networks requires encryption. Ruby's OpenSSL library wraps sockets with TLS, providing confidentiality and integrity.
require 'socket'
require 'openssl'
class SecureServer
def initialize(port, cert_file, key_file)
@tcp_server = TCPServer.new(port)
# Create SSL context
@ssl_context = OpenSSL::SSL::SSLContext.new
@ssl_context.cert = OpenSSL::X509::Certificate.new(File.read(cert_file))
@ssl_context.key = OpenSSL::PKey::RSA.new(File.read(key_file))
@ssl_context.ssl_version = :TLSv1_2_server
@ssl_context.ciphers = 'HIGH:!aNULL:!MD5'
@ssl_server = OpenSSL::SSL::SSLServer.new(@tcp_server, @ssl_context)
end
def run
loop do
ssl_client = @ssl_server.accept
Thread.new(ssl_client) { |client| handle_secure_client(client) }
end
end
private
def handle_secure_client(client)
puts "Secure connection from #{client.peeraddr[3]}"
puts "Cipher: #{client.cipher.first}"
# Normal socket operations work on SSL sockets
while line = client.gets
client.puts "Received: #{line}"
end
ensure
client.close
end
end
# Client with certificate verification
def secure_connect(host, port, ca_file)
tcp_socket = TCPSocket.new(host, port)
ssl_context = OpenSSL::SSL::SSLContext.new
ssl_context.ca_file = ca_file
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
ssl_socket.connect
ssl_socket.post_connection_check(host)
ssl_socket
end
TLS configuration requires certificates and keys. The ssl_version setting enforces secure protocol versions. Cipher configuration prevents weak algorithms. Client verification ensures connection to legitimate servers, preventing man-in-the-middle attacks. post_connection_check validates the certificate hostname matches the requested host.
Denial of Service Prevention
Network services face resource exhaustion attacks through excessive connections, slow clients, or large payloads. Rate limiting, timeouts, and resource quotas mitigate these threats.
require 'socket'
class RateLimitedServer
MAX_CONNECTIONS = 100
MAX_REQUEST_RATE = 10 # per second per client
def initialize(port)
@server = TCPServer.new(port)
@connections = {}
@mutex = Mutex.new
end
def run
loop do
client = @server.accept
client_ip = client.peeraddr[3]
@mutex.synchronize do
if @connections.count >= MAX_CONNECTIONS
client.puts "Server full, try again later"
client.close
next
end
@connections[client] = {
ip: client_ip,
requests: [],
connected_at: Time.now
}
end
Thread.new(client) { |c| handle_rate_limited_client(c) }
end
end
private
def handle_rate_limited_client(client)
while line = client.gets
unless check_rate_limit(client)
client.puts "Rate limit exceeded"
sleep 1 # Slow down abusive clients
next
end
process_request(client, line)
end
ensure
@mutex.synchronize { @connections.delete(client) }
client.close
end
def check_rate_limit(client)
@mutex.synchronize do
info = @connections[client]
now = Time.now
# Remove old requests (older than 1 second)
info[:requests].reject! { |time| now - time > 1 }
if info[:requests].count >= MAX_REQUEST_RATE
return false
end
info[:requests] << now
true
end
end
end
Connection limits prevent resource exhaustion. Per-client rate tracking detects and throttles abusive clients. Sliding window rate limiting provides more accurate rate control than fixed time windows. Additional protections include connection timeouts, maximum message sizes, and IP-based blocking after violations.
Authentication and Authorization
Network services require authentication to verify client identity and authorization to control resource access. Token-based authentication separates authentication from authorization.
require 'socket'
require 'securerandom'
require 'digest'
class AuthenticatedServer
def initialize(port)
@server = TCPServer.new(port)
@users = load_users
@sessions = {}
@mutex = Mutex.new
end
def handle_client(client)
session_id = authenticate(client)
return unless session_id
client.puts "Authenticated successfully"
while line = client.gets
command, *args = line.strip.split
unless authorized?(session_id, command)
client.puts "Unauthorized"
next
end
response = execute_command(command, args)
client.puts response
end
ensure
@mutex.synchronize { @sessions.delete(session_id) }
client.close
end
private
def authenticate(client)
client.puts "Username:"
username = client.gets.strip
client.puts "Password:"
password = client.gets.strip
user = @users[username]
return nil unless user
password_hash = Digest::SHA256.hexdigest(password + user[:salt])
return nil unless password_hash == user[:password_hash]
session_id = SecureRandom.hex(32)
@mutex.synchronize do
@sessions[session_id] = {
username: username,
permissions: user[:permissions],
created_at: Time.now
}
end
session_id
end
def authorized?(session_id, command)
@mutex.synchronize do
session = @sessions[session_id]
return false unless session
# Check if session expired (30 minutes)
if Time.now - session[:created_at] > 1800
@sessions.delete(session_id)
return false
end
session[:permissions].include?(command)
end
end
def load_users
{
'admin' => {
password_hash: Digest::SHA256.hexdigest('password' + 'salt123'),
salt: 'salt123',
permissions: ['STATUS', 'SHUTDOWN', 'CONFIG']
},
'user' => {
password_hash: Digest::SHA256.hexdigest('userpass' + 'salt456'),
salt: 'salt456',
permissions: ['STATUS']
}
}
end
end
Password hashing with salt prevents rainbow table attacks. Session tokens identify authenticated clients without transmitting passwords repeatedly. Session expiration limits the window for token theft. Permission-based authorization controls command access. Production systems should use established authentication frameworks and store credentials securely.
Tools & Ecosystem
Ruby's networking ecosystem includes standard library components and third-party gems providing higher-level abstractions and additional functionality for common socket programming tasks.
EventMachine
EventMachine implements the reactor pattern with a clean callback-based API. It handles connection management, multiplexing, and timer events, allowing focus on application logic.
require 'eventmachine'
module EchoServer
def post_init
@addr = Socket.unpack_sockaddr_in(get_peername)
puts "Client connected: #{@addr[1]}"
end
def receive_data(data)
send_data "Echo: #{data}"
end
def unbind
puts "Client disconnected: #{@addr[1]}"
end
end
EventMachine.run do
EventMachine.start_server '0.0.0.0', 8080, EchoServer
puts "Server running on port 8080"
end
EventMachine calls post_init when connections establish, receive_data when data arrives, and unbind when connections close. The library handles all socket operations and event loop management. This pattern suits applications with many concurrent connections and asynchronous operations.
Async and Async-IO
The async gem provides fiber-based concurrency with async/await semantics. Async-IO implements non-blocking I/O operations using fibers instead of callbacks.
require 'async'
require 'async/io/socket'
Async do |task|
endpoint = Async::IO::Endpoint.tcp('0.0.0.0', 8080)
endpoint.accept do |client|
task.async do
while line = client.read_until("\n")
client.write("Echo: #{line}")
end
end
end
end
Async uses cooperative scheduling where fibers yield during I/O operations. This provides concurrency without threads or callbacks. The task.async method spawns concurrent operations that run within the same thread, yielding control during blocking calls.
Celluloid
Celluloid combines actor model concurrency with Ruby's object-oriented programming. Each actor runs in its own thread, processing messages asynchronously.
require 'celluloid/io'
class ChatServer
include Celluloid::IO
def initialize(port)
@server = TCPServer.new(port)
@clients = []
async.run
end
def run
loop { async.handle_connection(@server.accept) }
end
def handle_connection(socket)
@clients << socket
while data = socket.readpartial(4096)
broadcast(data, socket)
end
rescue EOFError
@clients.delete(socket)
socket.close
end
def broadcast(message, sender)
@clients.each do |client|
client.write(message) unless client == sender
end
end
end
server = ChatServer.new(8080)
sleep
Celluloid actors isolate state and communicate through method calls that become asynchronous messages. The async method prefix makes calls non-blocking. This model prevents race conditions by serializing operations per actor while enabling concurrency between actors.
HTTParty and RestClient
Higher-level HTTP clients abstract socket details for REST API consumption. These gems handle connection pooling, redirects, and response parsing.
require 'httparty'
class APIClient
include HTTParty
base_uri 'api.example.com'
def initialize(api_key)
@options = { headers: { 'Authorization' => "Bearer #{api_key}" } }
end
def get_resource(id)
self.class.get("/resources/#{id}", @options)
end
def create_resource(data)
self.class.post('/resources', @options.merge(body: data.to_json))
end
end
HTTParty handles HTTP semantics, connection management, and response parsing. Applications focus on API interaction rather than low-level socket programming. These libraries remain useful references for understanding HTTP over sockets.
Socket.IO-Ruby
Socket.IO provides WebSocket communication with fallback mechanisms. The Ruby implementation enables real-time bidirectional communication between web clients and Ruby servers.
WebSocket upgrades HTTP connections to full-duplex socket connections, enabling server-push notifications and reduced latency. Socket.IO adds transport abstraction, automatic reconnection, and room/namespace organization.
Puma and Unicorn
Production Ruby web servers like Puma and Unicorn handle socket management for web applications. Understanding their architectures informs decisions about concurrency models and deployment.
Puma uses threads for concurrency, sharing memory between requests. It creates a thread pool and distributes connections across threads. This model balances memory efficiency and concurrency, particularly with Ruby implementations without a GIL.
Unicorn uses process forking for isolation. A master process manages worker processes, each handling one connection at a time. Slow clients tie up workers, but process isolation prevents one request from affecting others. Unix socket communication between master and workers coordinates connection distribution.
Reference
Socket Classes and Methods
| Class | Purpose | Key Methods |
|---|---|---|
| Socket | Low-level socket operations | socket, bind, listen, accept, connect, send, recv, close |
| TCPServer | TCP server creation | new, accept, accept_nonblock, listen, close |
| TCPSocket | TCP client connections | new, recv, send, read, write, close, peeraddr |
| UDPSocket | UDP datagram communication | new, bind, send, recvfrom, connect |
| UNIXServer | Unix domain server | new, accept, path |
| UNIXSocket | Unix domain client | new, recv_io, send_io |
| BasicSocket | Base class for sockets | getsockopt, setsockopt, shutdown, getpeername |
Common Socket Options
| Level | Option | Purpose | Values |
|---|---|---|---|
| SOL_SOCKET | SO_REUSEADDR | Allow address reuse | 0 or 1 |
| SOL_SOCKET | SO_KEEPALIVE | Enable TCP keepalive | 0 or 1 |
| SOL_SOCKET | SO_RCVTIMEO | Receive timeout | timeval struct |
| SOL_SOCKET | SO_SNDTIMEO | Send timeout | timeval struct |
| SOL_SOCKET | SO_RCVBUF | Receive buffer size | bytes |
| SOL_SOCKET | SO_SNDBUF | Send buffer size | bytes |
| SOL_TCP | TCP_NODELAY | Disable Nagle algorithm | 0 or 1 |
| SOL_TCP | TCP_KEEPIDLE | Keepalive idle time | seconds |
| SOL_TCP | TCP_KEEPINTVL | Keepalive interval | seconds |
| SOL_TCP | TCP_KEEPCNT | Keepalive probe count | integer |
Address Families
| Family | Description | Address Format | Use Case |
|---|---|---|---|
| AF_INET | IPv4 addressing | 32-bit address | Internet communication |
| AF_INET6 | IPv6 addressing | 128-bit address | Internet communication with larger address space |
| AF_UNIX | Unix domain sockets | Filesystem path | Local inter-process communication |
| AF_UNSPEC | Unspecified family | Varies | Address family agnostic operations |
Socket Types
| Type | Protocol | Connection | Reliability | Order | Boundaries |
|---|---|---|---|---|---|
| SOCK_STREAM | TCP | Connection-oriented | Reliable | Ordered | No message boundaries |
| SOCK_DGRAM | UDP | Connectionless | Unreliable | Unordered | Message boundaries preserved |
| SOCK_RAW | Various | Varies | Varies | Varies | Direct protocol access |
| SOCK_SEQPACKET | SCTP | Connection-oriented | Reliable | Ordered | Message boundaries preserved |
Error Codes
| Error | Meaning | Common Cause | Recovery |
|---|---|---|---|
| ECONNREFUSED | Connection refused | No listener on port | Verify service running, check port |
| ETIMEDOUT | Operation timed out | Network delay, host down | Retry with backoff, check network |
| EHOSTUNREACH | Host unreachable | Routing problem | Check network connectivity, routing |
| ENETUNREACH | Network unreachable | Network interface down | Verify network configuration |
| EADDRINUSE | Address in use | Port already bound | Change port or use SO_REUSEADDR |
| EPIPE | Broken pipe | Write to closed socket | Handle disconnect, check connection state |
| ECONNRESET | Connection reset | Remote forced close | Reconnect or handle as disconnect |
| EWOULDBLOCK | Would block | Non-blocking operation pending | Use select or retry later |
Connection States
| State | Description | Transitions |
|---|---|---|
| CLOSED | No connection | -> LISTEN (server) or -> SYN_SENT (client) |
| LISTEN | Waiting for connections | -> SYN_RCVD on incoming SYN |
| SYN_SENT | Connection request sent | -> ESTABLISHED on SYN-ACK |
| SYN_RCVD | Connection request received | -> ESTABLISHED on ACK |
| ESTABLISHED | Connection active | -> FIN_WAIT_1 on close, -> CLOSE_WAIT on FIN |
| FIN_WAIT_1 | Close initiated | -> FIN_WAIT_2 on ACK |
| FIN_WAIT_2 | Close acknowledged | -> TIME_WAIT on FIN |
| CLOSE_WAIT | Remote closed, local pending | -> LAST_ACK on close |
| LAST_ACK | Final acknowledgment pending | -> CLOSED on ACK |
| TIME_WAIT | Connection fully closed | -> CLOSED after 2MSL timeout |
IO.select Parameters
| Parameter | Type | Purpose | Notes |
|---|---|---|---|
| read_array | Array of IO | Sockets to monitor for reading | Returns when data available |
| write_array | Array of IO | Sockets to monitor for writing | Returns when send buffer has space |
| error_array | Array of IO | Sockets to monitor for errors | Returns on exceptional conditions |
| timeout | Numeric or nil | Maximum wait time | nil blocks indefinitely, 0 polls |
Socket Lifecycle Methods
| Phase | Server Methods | Client Methods |
|---|---|---|
| Creation | Socket.new, TCPServer.new | Socket.new, TCPSocket.new |
| Binding | bind | (automatic with TCPSocket.new) |
| Listening | listen | N/A |
| Connecting | accept | connect |
| Data Transfer | recv, send, read, write | recv, send, read, write |
| Shutdown | shutdown, close | shutdown, close |
Performance Tuning Options
| Technique | Implementation | Benefit | Tradeoff |
|---|---|---|---|
| TCP_NODELAY | setsockopt(SOL_TCP, TCP_NODELAY, 1) | Lower latency | Higher bandwidth usage |
| Buffer sizing | SO_RCVBUF, SO_SNDBUF | Higher throughput | More memory usage |
| Non-blocking I/O | fcntl(F_SETFL, O_NONBLOCK) | Better concurrency | Complex error handling |
| Connection pooling | Reuse connections | Reduced overhead | State management complexity |
| Multiplexing | IO.select, epoll | Many connections | Event-driven architecture |