Foundationপ্রথম নীতি থেকে
LEVEL 4কঠিন~২০ ঘণ্টাC

Build a Shell

Build a Shell

fork/exec/wait দিয়ে শুরু করে pipeline, redirection, আর job control পর্যন্ত — একটা কাজ করা Unix shell। প্রতিটা ফিচার আসলে দুই-তিনটা syscall-এর সঠিক ক্রম, আর সেই ক্রমটা ভুল হলে ঠিক কী ভাঙে সেটা হাতে-কলমে দেখা।

মাইলস্টোন

আগে যা পড়া দরকার

কেন এই প্রজেক্ট

একটা shell দেখতে সরল — prompt, command, output। কিন্তু ভেতরে এটা Level 4-এর প্রায় প্রতিটা ধারণার সমাবেশ: process creation, file descriptor, pipe, signal, process group, terminal control।

আর সবচেয়ে গুরুত্বপূর্ণ — shell-এর প্রতিটা ফিচার আসলে কয়েকটা syscall-এর একটা নির্দিষ্ট ক্রম। ক্রমটা ভুল হলে জিনিসটা কাজ করে না, কিন্তু error message-ও দেয় না — শুধু ঝুলে থাকে বা অদ্ভুত আচরণ করে। সেই ব্যর্থতাগুলো নিজে ঘটিয়ে দেখাই এই প্রজেক্টের আসল শিক্ষা।

আপনি প্রতিদিন ls | grep foo > out.txt লেখেন। এই প্রজেক্টের পর আপনি জানবেন সেই একটা লাইনের পেছনে ঠিক কয়টা syscall চলে, আর কোন ক্রমে।

ধাপে ধাপে

১. REPL

while (1) {
    printf("mysh$ ");
    fflush(stdout);
    if (!fgets(line, sizeof line, stdin)) break;   /* Ctrl-D → EOF */
    /* tokenise, dispatch */
}

cd কে built-in হতেই হবে — কেন? কারণ chdir process-এর নিজের অবস্থা বদলায়। Child-এ chdir করলে child মরে গেলেই সেটা হারিয়ে যায়। এটা একটা চমৎকার প্রশ্ন যেটা অনেকে প্রথমে ধরতে পারে না।

২. Fork + exec

pid_t pid = fork();
if (pid == 0) {
    execvp(argv[0], argv);
    perror("execvp");            /* এখানে পৌঁছানো মানে exec ব্যর্থ */
    _exit(127);                  /* exit() নয় — _exit() */
}
int status;
waitpid(pid, &status, 0);

_exit বনাম exit লক্ষ্য করুন: exit() atexit handler চালায় আর stdio buffer flush করে — fork করা child-এ সেটা parent-এর buffered output দ্বিতীয়বার ছাপাতে পারে।

৩. Redirection

int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);                       /* dup2-এর পর মূলটা আর দরকার নেই */

dup2 করার পরে close(fd) — কারণ dup2 fd 1 কে একই open file description-এ point করিয়ে দিয়েছে, তাই আসল fd অতিরিক্ত।

৪. Pipe — যেখানে সবাই আটকায়

int fds[2];
pipe(fds);                       /* fds[0] read, fds[1] write */

if (fork() == 0) {               /* বাঁ পাশ */
    dup2(fds[1], STDOUT_FILENO);
    close(fds[0]);               /* ⚠ অব্যবহৃত read end */
    close(fds[1]);
    execvp(...);
}
if (fork() == 0) {               /* ডান পাশ */
    dup2(fds[0], STDIN_FILENO);
    close(fds[0]);
    close(fds[1]);               /* ⚠ অব্যবহৃত write end */
    execvp(...);
}
close(fds[0]);                   /* ⚠ parent-ও বন্ধ করবে */
close(fds[1]);

তিন জায়গায় close করতে হয়। একটাও বাদ পড়লে pipe-এর write end খোলা থেকে যায়, তাই reader কখনো EOF পায় না — grep চিরকাল অপেক্ষা করবে।

এই bug-টা ইচ্ছে করে ঘটান। একটা close মন্তব্য করে দিয়ে ls | head -1 চালান — hang হবে। তারপর অন্য terminal থেকে ls -l /proc/<pid>/fd দেখুন, খোলা fd-টা চোখে পড়বে।

৫. Job control

সবচেয়ে কঠিন অংশ, আর সবচেয়ে কম বোঝা।

setpgid(pid, pid);               /* child কে নিজের process group-এ */
tcsetpgrp(STDIN_FILENO, pid);    /* terminal-এর foreground group */

Terminal driver Ctrl-C পেলে SIGINT পাঠায় foreground process group-কে। তাই shell-কে নিজেকে SIGINT থেকে বাঁচাতে হবে (signal(SIGINT, SIG_IGN)) কিন্তু job-কে পেতে দিতে হবে।

Background job terminal থেকে পড়তে চাইলে SIGTTIN পায় আর থেমে যায় — এটাই কেন sleep 100 & ঠিক আছে কিন্তু cat & থেমে যায়।

নিজেকে চ্যালেঞ্জ করুন

  1. 2>&1 — stderr কে stdout-এ merge, আর ক্রম কেন গুরুত্বপূর্ণ (cmd > f 2>&1 বনাম cmd 2>&1 > f — দুটো ভিন্ন জিনিস, কেন?)
  2. Here-document (\<\<EOF) — একটা temp file বা pipe দিয়ে
  3. $?, $$, environment variable expansion, আর export
  4. Command substitution $(...) — recursion লাগবে
  5. Tab completion — readline library দিয়ে, বা নিজে termios raw mode-এ
  6. && আর || — exit status দেখে short-circuit
  7. History আর !!

যা এখানে শিখলেন, পরে যেখানে লাগবে

এখানেপরে
fork/exec/waitLevel 12 — container runtime-ও ঠিক এটাই করে, শুধু namespace flag সহ
fd hygieneLevel 7 — socket server-এ fd leak একই ভাবে ধরা পড়ে
Process group ও signalLevel 12 — Kubernetes-এ PID 1 আর signal forwarding
PipelineLevel 8 — query executor-ও একটা pipeline, একই backpressure ধারণা