What Are We Building?
A TCP server that stores name-value pairs in memory. Clients connect and can perform four operations: SET (add/modify), GET (retrieve), DEL (delete), and LIST (show all keys). This is essentially a simplified version of what Redis does.
The interesting challenges here are: how to design a clear text protocol over TCP, how to handle multiple clients simultaneously, and how to maintain consistent shared state between client sessions.
Protocol Design
Before writing a single line of server code, define the protocol — the exact format of messages exchanged between client and server. A well-designed protocol prevents ambiguity and makes debugging much easier.
| Command | Client Sends | Server Responds |
|---|---|---|
| SET | SET key value\n | OK\n or ERR msg\n |
| GET | GET key\n | VALUE val\n or ERR not found\n |
| DEL | DEL key\n | OK\n or ERR not found\n |
| LIST | LIST\n | KEY key\n … END\n |
| QUIT | QUIT\n | BYE\n (then close) |
The Data Store
We’ll use a simple fixed-size array of key-value pairs. This avoids external libraries and keeps the focus on socket programming. For production use, you would use a hash table.
/* kvstore.h — Simple key-value store */
#ifndef KVSTORE_H
#define KVSTORE_H
#define MAX_ENTRIES 256
#define MAX_KEY_LEN 64
#define MAX_VAL_LEN 256
typedef struct {
char key[MAX_KEY_LEN];
char val[MAX_VAL_LEN];
int in_use; /* 1 if this slot is occupied, 0 if free */
} KVEntry;
/* Global store (server-wide) */
extern KVEntry store[MAX_ENTRIES];
int kv_set(const char *key, const char *val);
int kv_get(const char *key, char *val_out, size_t val_out_len);
int kv_del(const char *key);
void kv_list(int connfd);
#endif
/* kvstore.c */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "kvstore.h"
KVEntry store[MAX_ENTRIES];
/* Find slot for key, or first free slot */
static int find_slot(const char *key)
{
for (int i = 0; i < MAX_ENTRIES; i++) {
if (store[i].in_use && strcmp(store[i].key, key) == 0)
return i;
}
return -1; /* Not found */
}
static int find_free(void)
{
for (int i = 0; i < MAX_ENTRIES; i++) {
if (!store[i].in_use)
return i;
}
return -1; /* Store full */
}
/* SET: add or update key */
int kv_set(const char *key, const char *val)
{
int slot = find_slot(key);
if (slot == -1) {
slot = find_free();
if (slot == -1)
return -1; /* Store full */
strncpy(store[slot].key, key, MAX_KEY_LEN - 1);
store[slot].in_use = 1;
}
strncpy(store[slot].val, val, MAX_VAL_LEN - 1);
return 0;
}
/* GET: retrieve value for key */
int kv_get(const char *key, char *val_out, size_t len)
{
int slot = find_slot(key);
if (slot == -1)
return -1; /* Not found */
strncpy(val_out, store[slot].val, len - 1);
val_out[len - 1] = '\0';
return 0;
}
/* DEL: remove key */
int kv_del(const char *key)
{
int slot = find_slot(key);
if (slot == -1)
return -1; /* Not found */
store[slot].in_use = 0;
memset(&store[slot], 0, sizeof(KVEntry));
return 0;
}
/* LIST: send all keys to connfd */
void kv_list(int connfd)
{
char line[MAX_KEY_LEN + 8];
for (int i = 0; i < MAX_ENTRIES; i++) {
if (store[i].in_use) {
snprintf(line, sizeof(line), "KEY %s\n", store[i].key);
write(connfd, line, strlen(line));
}
}
write(connfd, "END\n", 4);
}
The Concurrent Server
Each client gets its own child process using fork(). This is the simplest approach to handle multiple concurrent clients. Each child handles one client and then exits. The parent never touches client data — it just accepts and forks.
Client A
Client B
Client C
/* nv_server.c — Name-Value Store Server */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include "rlbuf.h"
#include "inet_sockets.h"
#include "kvstore.h"
#define PORT "54321"
#define BACKLOG 50
#define MAXLINE 512
/* Reap zombie children */
static void sigchld_handler(int sig)
{
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
/* Parse and handle one command line from a client */
static void handle_command(int connfd, const char *line)
{
char cmd[16], key[MAX_KEY_LEN], val[MAX_VAL_LEN];
char resp[MAX_VAL_LEN + 16];
int n;
n = sscanf(line, "%15s %63s %255[^\n]", cmd, key, val);
if (n < 1) {
write(connfd, "ERR empty command\n", 18);
return;
}
if (strcmp(cmd, "SET") == 0) {
if (n < 3) {
write(connfd, "ERR SET needs key and value\n", 28);
} else if (kv_set(key, val) == 0) {
write(connfd, "OK\n", 3);
} else {
write(connfd, "ERR store full\n", 15);
}
} else if (strcmp(cmd, "GET") == 0) {
if (n < 2) {
write(connfd, "ERR GET needs key\n", 18);
} else if (kv_get(key, val, sizeof(val)) == 0) {
snprintf(resp, sizeof(resp), "VALUE %s\n", val);
write(connfd, resp, strlen(resp));
} else {
write(connfd, "ERR not found\n", 14);
}
} else if (strcmp(cmd, "DEL") == 0) {
if (n < 2) {
write(connfd, "ERR DEL needs key\n", 18);
} else if (kv_del(key) == 0) {
write(connfd, "OK\n", 3);
} else {
write(connfd, "ERR not found\n", 14);
}
} else if (strcmp(cmd, "LIST") == 0) {
kv_list(connfd); /* Sends "KEY k\n" for each key, then "END\n" */
} else {
snprintf(resp, sizeof(resp), "ERR unknown command: %s\n", cmd);
write(connfd, resp, strlen(resp));
}
}
int main(void)
{
int listenfd, connfd;
socklen_t addrlen;
struct sigaction sa;
RlBuf rlbuf;
char line[MAXLINE];
/* Reap zombie children automatically */
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);
listenfd = inetListen(PORT, BACKLOG, &addrlen);
if (listenfd == -1) { perror("inetListen"); exit(EXIT_FAILURE); }
printf("Name-Value server listening on port %s\n", PORT);
for (;;) {
connfd = accept(listenfd, NULL, NULL);
if (connfd == -1) { perror("accept"); continue; }
switch (fork()) {
case -1:
perror("fork");
close(connfd);
break;
case 0:
/* Child process: handle this client */
close(listenfd); /* Child doesn't need listen fd */
initRlBuf(connfd, &rlbuf);
write(connfd, "Welcome to KV Store. Commands: SET GET DEL LIST QUIT\n", 53);
while (readLineBuf(&rlbuf, line, sizeof(line)) > 0) {
/* Trim trailing newline */
size_t len = strlen(line);
while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
line[--len] = '\0';
if (strcmp(line, "QUIT") == 0) {
write(connfd, "BYE\n", 4);
break;
}
handle_command(connfd, line);
}
close(connfd);
exit(EXIT_SUCCESS);
default:
/* Parent: close connfd (child has it) and loop */
close(connfd);
break;
}
}
return 0;
}
The Client
/* nv_client.c — Interactive Name-Value Store Client */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "rlbuf.h"
#include "inet_sockets.h"
#define PORT "54321"
#define MAXLINE 512
int main(int argc, char *argv[])
{
int sockfd;
RlBuf rlbuf;
char cmd[MAXLINE];
char resp[MAXLINE];
if (argc != 2) {
fprintf(stderr, "Usage: %s host\n", argv[0]);
exit(EXIT_FAILURE);
}
sockfd = inetConnect(argv[1], PORT, SOCK_STREAM);
if (sockfd == -1) { perror("inetConnect"); exit(EXIT_FAILURE); }
initRlBuf(sockfd, &rlbuf);
/* Read and print server welcome message */
if (readLineBuf(&rlbuf, resp, sizeof(resp)) > 0)
printf("Server: %s", resp);
/* Interactive loop */
while (fgets(cmd, sizeof(cmd), stdin) != NULL) {
size_t len = strlen(cmd);
/* Send command to server */
if (write(sockfd, cmd, len) != (ssize_t)len) {
perror("write"); break;
}
/* Read response lines until we get a final line */
/* For LIST: read until "END\n"; for others: read one line */
do {
if (readLineBuf(&rlbuf, resp, sizeof(resp)) <= 0) goto done;
printf("%s", resp);
} while (strncmp(resp, "KEY ", 4) == 0); /* LIST responses */
if (strncmp(cmd, "QUIT", 4) == 0) break;
}
done:
close(sockfd);
return 0;
}
Build and Demo Session
gcc -Wall -o nv_server nv_server.c kvstore.c inet_sockets.c rlbuf.c
gcc -Wall -o nv_client nv_client.c inet_sockets.c rlbuf.c
# Terminal 1: start server
./nv_server
Name-Value server listening on port 54321
# Terminal 2: connect client
./nv_client localhost
Server: Welcome to KV Store. Commands: SET GET DEL LIST QUIT
SET city Hyderabad
OK
SET lang C
OK
GET city
VALUE Hyderabad
LIST
KEY city
KEY lang
END
DEL city
OK
LIST
KEY lang
END
QUIT
BYE
The Shared State Problem
The fork-per-client design has a fundamental limitation: each child gets a copy of the parent’s memory, not a reference. Two clients running simultaneously see different independent stores. Fixes:
Map the store into a shared memory segment. All children see the same data. Requires a mutex (semaphore) to prevent concurrent write corruption.
Use I/O multiplexing so one process handles all clients. No fork, no shared-state problem. More complex code but very scalable. This is what Redis and nginx do.
Children don’t touch the store directly. They send requests to the parent via a pipe, parent executes them and sends back results. The parent is the single point of access.
Each operation reads/writes a file or embedded database. Automatic persistence across restarts. SQLite is thread-safe and supports this pattern well.
Basic Security: Creator-Only Delete
The exercise asks for an optional security mechanism where only the client that created a key can delete or modify it. We can track the IP address of the creator:
/* Extended KVEntry with owner tracking */
typedef struct {
char key[MAX_KEY_LEN];
char val[MAX_VAL_LEN];
int in_use;
char owner_ip[INET6_ADDRSTRLEN]; /* IP of creator */
uid_t owner_uid; /* UID (for UNIX socket clients) */
} KVEntry;
/* Get client IP address from accept() */
int get_client_ip(int connfd, char *ip_out, size_t len)
{
struct sockaddr_storage peer_addr;
socklen_t peer_len = sizeof(peer_addr);
if (getpeername(connfd, (struct sockaddr *)&peer_addr, &peer_len) == -1)
return -1;
/* Convert to string */
return getnameinfo((struct sockaddr *)&peer_addr, peer_len,
ip_out, len, NULL, 0, NI_NUMERICHOST);
}
/* In kv_del — check ownership */
int kv_del_secure(const char *key, const char *client_ip)
{
int slot = find_slot(key);
if (slot == -1)
return -1; /* Not found */
/* Reject if requester is not the owner */
if (strcmp(store[slot].owner_ip, client_ip) != 0)
return -2; /* Permission denied */
store[slot].in_use = 0;
return 0;
}
/*
* NOTE: IP-based ownership is weak security — NAT and proxies mean
* multiple users can share an IP. For real security, use authentication
* (e.g. a shared secret or token-based auth before allowing mutations).
*/
Interview Questions & Answers
The protocol defines the contract between client and server. If you write server code first, you’ll likely need to redesign it when edge cases emerge (what if the key has spaces? what if the value is missing?). Defining the protocol first — including error responses — prevents rework and makes both sides independently testable. You can test with telnet before writing the client.
After fork(), both parent and child have a copy of the file descriptor. A TCP connection only closes when ALL copies of its fd are closed. If the parent doesn’t close its copy of connfd, the client will never see EOF even after the child exits — because the parent’s copy keeps the connection open. Always close the fd you’re not using.
The child doesn’t need to accept any new connections — that’s the parent’s job. File descriptors are a limited resource (typically 1024 per process). If the child keeps listenfd open, it wastes an fd and could interfere with the parent’s ability to accept new connections in some edge cases. Good practice: close everything you don’t need.
When a child process exits, the kernel keeps its exit status until the parent calls waitpid(). Until then, the process entry remains — a “zombie”. In a server that forks many children, accumulating zombies wastes process table entries. We prevent this by installing a SIGCHLD handler that calls waitpid(-1, NULL, WNOHANG) in a loop to reap all available children without blocking.
fork() uses copy-on-write to give each child a copy of the parent’s address space. Changes made by one child to its copy of the store are invisible to other children. To share mutable state between processes, you need shared memory (mmap(MAP_SHARED) or POSIX shared memory) protected by a semaphore, or a single-process event-driven design.
Instead of forking a child per client, a single process uses select() or epoll() to monitor multiple file descriptors simultaneously. When any fd becomes readable, the process handles that client. No fork overhead, no shared state problem, and very high scalability. This is how Redis handles thousands of clients in a single thread. The tradeoff: more complex code, and a slow command blocks all other clients (unlike fork where each child is isolated).
Use telnet hostname port or nc (netcat). Both connect to a TCP server and let you type commands and see responses directly: nc localhost 54321 then type SET foo bar and press Enter. This is why line-based text protocols are popular for application-layer protocols — they’re easy to debug manually.
sscanf(line, "%15s %63s %255[^\n]", cmd, key, val) extracts up to three space-separated tokens from the line. The format %[^\n] captures everything until newline, allowing spaces in the value. Limitations: it doesn’t handle quoted strings with embedded spaces as single tokens, and it’s fragile if the client sends malformed input. Production servers usually use a proper parser. Also, sscanf doesn’t distinguish between “no third token” and “empty third token” cleanly.
