Unix Shell & Networked Chat

Built a Unix-style shell from scratch in C with process management, multi-stage pipes, signals, and custom built-ins, then extended it with socket programming to support a client-server chat system.

Overview

I built mysh to understand what a command shell is doing beneath the prompt. It parses commands, expands shell variables, connects multi-stage pipelines, manages foreground and background processes, and exposes a TCP chat server and client as built-in commands.

The simplest mental model is that mysh translates text into operating-system work. Parsing determines what the user requested; fork and execvp create and replace processes; dup2 rewires their input and output; waitpid collects finished children; and select keeps network sockets responsive without adding threads.

Command lifecycle

The interactive loop first collects completed background processes, services the chat server, then reads and executes the next command. SIGINT returns control to the prompt instead of terminating the shell, while SIGPIPE is ignored so a disconnected client becomes a recoverable write failure.

  1. Read Collect one command line
  2. Parse Tokenize and identify syntax
  3. Expand Resolve shell variables
  4. Execute Run a builtin or process
  5. Clean up Wait, reap, close, and free
Command flow through the shell
while ((exited_pid = waitpid(-1, &stat, WNOHANG)) > 0) {
    if (get_process_num(exited_pid) != -1) {
        del_process(exited_pid);
    }
}

if (server_running) {
    handle_server();
}
get_input2(input_buf);

Parsing and variable expansion

Input is normalized and tokenized before execution. Variables live in a linked list and are expanded token by token, including chained expressions such as abc$first$second. Bounded appends keep each expanded token within its allocated buffer.

late_dollar = strchr(early_dollar + 1, '$');

while (early_dollar != NULL) {
    if (late_dollar != NULL) {
        *late_dollar = '\0';
        char *expanded_var = find_variable(early_dollar + 1);
        strncat(expanded_token, expanded_var, 128 - strlen(expanded_token));
        early_dollar = late_dollar;
        late_dollar = strchr(late_dollar + 1, '$');
    } else {
        strncat(expanded_token, find_variable(early_dollar + 1),
                128 - strlen(expanded_token));
        early_dollar = NULL;
    }
}

Pipelines

For N commands, the shell creates N-1 pipes and forks one child per stage. Each child connects the correct pipe ends to standard input or output with dup2, then closes every inherited descriptor. Closing unused write ends is critical; otherwise downstream commands may wait forever for EOF.

if (i > 0) {
    dup2(pipes[i - 1][0], STDIN_FILENO);
}
if (i < nc - 1) {
    dup2(pipes[i][1], STDOUT_FILENO);
}

for (int j = 0; j < nc - 1; j++) {
    close(pipes[j][0]);
    close(pipes[j][1]);
}
execute_command(commands[i], 0, 0);

Background jobs

A trailing ampersand returns control to the prompt immediately. The parent records the PID, job number, and command in a linked list; the child redirects standard input to /dev/null so it cannot consume terminal input. Non-blocking waitpid calls later remove completed jobs without creating zombies.

pid_t pid = fork();

if (pid > 0) {
    add_process(token_arr, pid, processCount);
    free(finput_buf);
    continue;
}

int devnull = open("/dev/null", O_RDONLY);
dup2(devnull, STDIN_FILENO);
close(devnull);

Non-blocking network chat

The chat extension uses TCP sockets and select rather than threads. The server watches its listening socket and every connected client, then accepts, broadcasts, or removes clients without blocking the shell. A persistent client watches both the socket and standard input, allowing incoming messages and typed commands to share one loop.

FD_ZERO(&fds);
FD_SET(STDIN_FILENO, &fds);
FD_SET(fd, &fds);
int max_fd = STDIN_FILENO > fd ? STDIN_FILENO : fd;

struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 100000;
int ready = select(max_fd + 1, &fds, NULL, NULL, &tv);

if (FD_ISSET(fd, &fds)) {
    ssize_t n = read(fd, msg, sizeof(msg) - 1);
}
if (FD_ISSET(STDIN_FILENO, &fds)) {
    expand_input(tmp_tokens, 1);
}

How the pieces fit together

A normal command is read, tokenized, expanded, and dispatched as either a builtin or an external program. A pipeline repeats that execution path across several child processes, with file descriptors connecting one stage's output to the next stage's input. A background command follows the same process model, but the parent returns to the prompt instead of waiting immediately.

The chat feature applies the same file-descriptor thinking to sockets. select reports which descriptors are ready, so one loop can react to terminal input, new connections, client messages, and disconnects. The central systems lesson is that correct cleanup matters as much as execution: every process, allocation, pipe end, and socket needs a clear owner and lifetime.