A TCP echo server sounds trivial. Accept a connection, read data, send it back. You could write one in 15 lines of Python. But the moment you start adding real-world requirements — concurrent connection limits, idle timeouts, configurable transforms, structured logging — it becomes a surprisingly good exercise in async systems design.
In this article I'll walk through tcpecho-rs, a configurable TCP echo server built with Rust and Tokio. The interesting parts aren't the echo itself — it's the semaphore-based connection limiting, the pipeline of composable transforms, and the timeout handling that prevents resource leaks from idle connections.
Why build an echo server?
Echo servers are the printf of network programming. When you're debugging a protocol, testing a load balancer, verifying firewall rules, or benchmarking a proxy, you need a predictable endpoint that does exactly what you expect. A good echo server is more than just cat over a socket — it needs to be observable (logging), controllable (connection limits, timeouts), and occasionally transformative (when you need to verify your client handles different response formats).
The transforms in tcpecho-rs — uppercase, hex encoding, prefix strings, artificial delays — each simulate a real testing scenario:
-
--uppercase: Does my client handle case-insensitive protocols correctly? -
--hex: Can I verify binary data is being transmitted correctly? -
--prefix: Does my parser handle protocol-specific response prefixes? -
--delay: Does my client handle slow servers without crashing?
Architecture: lib.rs for logic, main.rs for wiring
The project follows a pattern I've found effective in Rust CLIs: all pure logic lives in lib.rs where it's trivially unit-testable, and main.rs is a thin shell that wires the logic into the async runtime.
The Config struct
Everything flows from a single configuration struct:
pub struct Config {
pub port: u16,
pub uppercase: bool,
pub hex: bool,
pub prefix: Option<String>,
pub delay: Option<Duration>,
pub max_conn: usize,
pub timeout: Option<Duration>,
}
This is constructed from CLI flags in main.rs and passed (via Arc) to every connection handler. No global state, no mutation, no hidden configuration.
The transform pipeline
The transform function applies a fixed pipeline to each line:
pub fn transform_line(input: &str, config: &Config) -> String {
// 1. Trim trailing newline
let trimmed = input.trim_end_matches(|c| c == '\n' || c == '\r');
// 2. Hex encode (operates on raw bytes)
let after_hex = if config.hex {
hex_encode(trimmed.as_bytes())
} else {
trimmed.to_string()
};
// 3. Uppercase
let after_upper = if config.uppercase {
after_hex.to_uppercase()
} else {
after_hex
};
// 4. Prefix
let after_prefix = match &config.prefix {
Some(pfx) => format!("{pfx}{after_upper}"),
None => after_upper,
};
// 5. Re-add newline
format!("{after_prefix}\n")
}
The order is deliberate and fixed: hex runs before uppercase (so hex digits like a-f get uppercased if both flags are set), and prefix runs last (so the prefix itself is never transformed). This eliminates any ambiguity about flag interaction.
A key design decision: every transform flag is independent. The function doesn't need if hex && uppercase branches — it's a linear pipeline where each stage either transforms or passes through. Adding a new transform means adding one more stage, not modifying any existing logic.
Hex encoding without dependencies
Rather than pulling in a hex crate for a trivial operation, the hex encoder is hand-rolled:
pub fn hex_encode(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ")
}
Space-separated pairs make the output readable: "Hi" becomes "48 69" rather than "4869". This is a display tool, not a serialization format — readability matters more than compactness.
Semaphore-based connection limiting
Unbounded concurrency is the default trap in async servers. Without limits, a misbehaving client or a deliberate attack can open thousands of connections, exhausting file descriptors, memory, or CPU.
The solution is a Tokio semaphore that caps the number of active connections:
let semaphore = if config.max_conn > 0 {
Some(Arc::new(Semaphore::new(config.max_conn)))
} else {
None
};
When a new connection arrives, we try to acquire a permit:
let _permit = if let Some(ref s) = sem {
match s.try_acquire() {
Ok(p) => Some(p),
Err(_) => {
let _ = stream.try_write(b"ERROR: max connections reached\n");
return;
}
}
} else {
None
};
Notice we use try_acquire(), not acquire().await. The difference matters: acquire() would block the task until a permit becomes available, queuing excess connections. try_acquire() fails immediately, letting us send a rejection message and close the connection. This is the right behavior for a test server — the client should know immediately that it was rejected, not silently hang.
The permit is held as _permit for the lifetime of the connection handler. When the handler returns (client disconnects, timeout, or error), the permit is dropped, releasing the slot for the next connection. The underscore prefix is Rust's idiom for "I need this value alive for its drop side effect, but I'll never read it."
Why not a connection pool?
A common alternative is maintaining a Vec<JoinHandle> and checking its length. But this requires bookkeeping — adding handles, removing completed ones, handling panics. The semaphore encapsulates all of this: permits are acquired and released automatically through Rust's ownership system. No manual cleanup, no race conditions around the count.
Idle timeout handling
Long-lived connections that stop sending data are a resource leak. The --timeout flag sets a per-connection idle timeout:
let read_result = if let Some(timeout_dur) = config.timeout {
match tokio::time::timeout(timeout_dur, buf_reader.read_line(&mut line)).await {
Ok(r) => r,
Err(_) => {
let _ = writer.write_all(b"ERROR: idle timeout\n").await;
return;
}
}
} else {
buf_reader.read_line(&mut line).await
};
The tokio::time::timeout wrapper races a timer against the read operation. If the timer fires first, we get Err(_), send a message to the client, and close the connection. If data arrives first, we get Ok(r) and process it normally.
The timeout resets on every line. A client that sends data every 29 seconds with a 30-second timeout will never be disconnected. This is "idle" timeout, not "total connection" timeout — the distinction matters for long-running test sessions.
The timeout wraps the read, not the connection
A subtle but important choice: the timeout is per-read, not per-connection. Wrapping the entire handle_connection function would disconnect active clients after N seconds regardless of activity. Wrapping just read_line means the timeout only fires during periods of inactivity.
The async server loop
The main server loop is straightforward tokio:
loop {
let (stream, peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
eprintln!("{} accept error: {}", format_timestamp(), e);
continue;
}
};
eprintln!("{} connected: {}", format_timestamp(), peer);
let cfg = config.clone();
let sem = semaphore.clone();
tokio::spawn(async move {
// acquire permit, handle connection, log disconnect
});
}
Each connection gets its own spawned task. The config is shared via Arc<Config> (cloning the Arc, not the Config). The semaphore is also Arc-wrapped so permits can be moved into spawned tasks.
Accept errors are logged and skipped rather than crashing the server. This handles transient conditions like file descriptor exhaustion — the server stays up and recovers when resources free up.
Timestamps without chrono
Logging needs timestamps. Rather than adding a datetime crate, the timestamp formatter does manual UTC conversion:
pub fn format_timestamp() -> String {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let (year, month, day) = days_to_ymd(days);
format!("[{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02}]")
}
The days_to_ymd function uses Howard Hinnant's civil calendar algorithm, which handles leap years correctly without any external dependencies. For a logging timestamp, UTC is perfectly adequate — we don't need timezone awareness.
Duration parsing
Both --delay and --timeout accept human-readable durations:
pub fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if let Some(rest) = s.strip_suffix("ms") {
rest.trim().parse::<u64>()
.map(Duration::from_millis)
.map_err(|e| e.to_string())
} else if let Some(rest) = s.strip_suffix('s') {
rest.trim().parse::<u64>()
.map(Duration::from_secs)
.map_err(|e| e.to_string())
} else {
s.parse::<u64>()
.map(Duration::from_millis)
.map_err(|e| format!("expected duration like 500ms or 30s: {e}"))
}
}
This accepts 500ms, 30s, or bare numbers (treated as milliseconds). It's registered as a clap value_parser, so invalid durations produce proper error messages at argument-parsing time, not at server startup.
Testing strategy
The project has 36 tests across three layers:
Unit tests (22 tests): All transform logic, hex encoding, duration parsing, timestamp formatting, color palette behavior, and calendar math. These are pure functions — no I/O, no async, instant execution.
Integration tests (9 tests): Spawn a real TCP server on 127.0.0.1:0, connect via TcpStream, send data, assert responses. These test the full pipeline including line buffering, connection lifecycle, and feature interactions:
#[tokio::test]
async fn echo_uppercase() {
let cfg = Config { uppercase: true, ..Config::default() };
let (addr, handle) = spawn_echo_server(cfg).await;
let resp = send_line(addr, "hello").await;
assert_eq!(resp, "HELLO\n");
handle.abort();
}
The bind("127.0.0.1:0") pattern lets the OS assign a free port, avoiding conflicts when tests run in parallel.
CLI tests (5 tests): Using assert_cmd to run the compiled binary and verify help text, version output, and error handling for invalid arguments.
The test pyramid is intentionally bottom-heavy. Transform logic has the most tests because it has the most edge cases. Integration tests verify the async wiring works. CLI tests catch argument-parsing regressions.
Notable integration tests:
-
delay_adds_latency: Verifies that--delay 100msactually adds measurable latency to responses. -
idle_timeout_disconnects: Connects but sends nothing, verifies the server disconnects after the timeout. -
max_conn_rejects_excess: Opens one connection at the max-conn limit, then verifies a second connection is rejected with an error message.
Color handling
Logs go to stderr (data goes to stdout in a traditional Unix tool, but since this is a server, there's no stdout output — all logging is stderr). Colors are controlled by a Palette struct that's constructed once at startup:
pub struct Palette { enabled: bool }
impl Palette {
fn pick(&self, code: &'static str) -> &'static str {
if self.enabled { code } else { "" }
}
pub fn ok(&self) -> &'static str { self.pick("\x1b[32m") }
pub fn warn(&self) -> &'static str { self.pick("\x1b[33m") }
}
Color is disabled when stderr is not a terminal, when NO_COLOR is set, or when --no-color is passed. The IsTerminal trait from std eliminates the need for a terminal-detection crate.
Docker deployment
The Dockerfile uses a two-stage build:
FROMrust:1.90-alpineASbuilder
# ... build with musl for static linking ...
FROM alpine:3.20
RUN adduser -D -u 1000 echoer
USER echoer
COPY --from=builder /build/target/release/tcpecho-rs /usr/local/bin/
EXPOSE 7777
ENTRYPOINT ["/usr/local/bin/tcpecho-rs"]
Alpine + musl produces a statically-linked binary. The runtime image is ~7 MB. The non-root user follows container security best practices.
What I'd add next
-
TLS support: For testing HTTPS clients. Would need
tokio-rustlsas a dependency. - Binary mode: Echo raw bytes without line buffering, for testing binary protocols.
- Metrics endpoint: Connection count, bytes transferred, uptime — useful when the echo server itself is the system under test.
- Request logging: Optionally log the content of each echoed line, not just connection events.
Source code
The full source is available on GitHub. Two runtime dependencies: clap for argument parsing, tokio for the async runtime. Everything else — transforms, hex encoding, timestamps, color output — is hand-rolled.
This is entry #194 in a series where I build 200 small tools and libraries. Each one focuses on a specific technique or pattern worth understanding.