Skip to content

Exam Rank 04 Tutorial

Prerequisites: Review Exam Rank 04 Concepts first.

IMPORTANT: Exam Rank 04 is pure C, not C++. These exercises test Unix system programming fundamentals: processes, pipes, file descriptors, and parsing.

The exam has two levels: Level 1 tests process management, Level 2 tests parsing. You’ll get one exercise from each level.



Implement a function that executes a pipeline of commands:

  • Input: array of commands (each command is array of strings)
  • Connect stdout of each command to stdin of the next
  • Return 0 on success, 1 on error
  • Close all file descriptors, wait for all children

Example pipeline: ls | grep .c | wc -l

char *cmd1[] = {"ls", NULL};
char *cmd2[] = {"grep", ".c", NULL};
char *cmd3[] = {"wc", "-l", NULL};
char **cmds[] = {cmd1, cmd2, cmd3, NULL};
picoshell(cmds);

Think before coding:

  1. What is a pipe?

    • pipe(fd) creates two file descriptors
    • fd[0] = read end, fd[1] = write end
    • Data written to fd[1] can be read from fd[0]
  2. Pipeline pattern:

    cmd1 stdout --> [pipe1] --> stdin cmd2 stdout --> [pipe2] --> stdin cmd3
  3. For each command:

    • Fork a child process
    • In child: redirect stdin/stdout, exec the command
    • In parent: track file descriptors, wait for children
  4. Critical rule: Close ALL unused file descriptors in both parent AND child!

Stage 1: Basic structure

picoshell.c
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int picoshell(char **cmds[])
{
int i = 0;
int prev_fd = -1; // Read end from previous pipe
int pipefd[2];
pid_t pid;
// Count commands
while (cmds[i])
i++;
int cmd_count = i;
// Process each command
for (i = 0; cmds[i]; i++)
{
// ... implementation here
}
// Wait for all children
for (i = 0; i < cmd_count; i++)
wait(NULL);
return 0;
}

Stage 2: Create pipes and fork

picoshell.c - pipe and fork
for (i = 0; cmds[i]; i++)
{
// Create pipe if not the last command
if (cmds[i + 1] && pipe(pipefd) == -1)
{
if (prev_fd != -1)
close(prev_fd);
return 1;
}
pid = fork();
if (pid == -1)
{
if (prev_fd != -1)
close(prev_fd);
if (cmds[i + 1])
{
close(pipefd[0]);
close(pipefd[1]);
}
return 1;
}
// Child or parent handling...
}

Stage 3: Child process - redirect and exec

picoshell.c - child
if (pid == 0)
{
// Child process
// Redirect stdin from previous pipe (if not first)
if (prev_fd != -1)
{
dup2(prev_fd, STDIN_FILENO);
close(prev_fd);
}
// Redirect stdout to current pipe (if not last)
if (cmds[i + 1])
{
close(pipefd[0]); // Close read end (won't use)
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
}
execvp(cmds[i][0], cmds[i]);
exit(1); // exec failed - MUST exit!
}

Stage 4: Parent process - manage fds

picoshell.c - parent
// Parent process
if (prev_fd != -1)
close(prev_fd); // Done with previous read end
if (cmds[i + 1])
{
close(pipefd[1]); // Close write end (child uses it)
prev_fd = pipefd[0]; // Save read end for next command
}

Understanding dup2:

CallEffect
dup2(pipefd[1], STDOUT_FILENO)stdout now writes to pipe
dup2(prev_fd, STDIN_FILENO)stdin now reads from previous pipe

Pipe data flow:

Before dup2:
stdin (0) --> keyboard
stdout (1) --> terminal
After dup2 in middle command:
stdin (0) --> previous pipe read end
stdout (1) --> current pipe write end

Why close after dup2?

dup2(pipefd[1], STDOUT_FILENO); // Creates copy at fd 1
close(pipefd[1]); // Original no longer needed
// Now: stdout (1) points to pipe write
// pipefd[1] is closed (not leaked)

1. Forgetting to close pipe ends:

// WRONG: Pipe read end never closed
if (fork() == 0) {
dup2(pipefd[1], 1);
execvp(cmd, argv);
}
// RIGHT: Close all unused ends
if (fork() == 0) {
close(pipefd[0]); // Not reading
dup2(pipefd[1], 1);
close(pipefd[1]); // Closed after dup2
execvp(cmd, argv);
exit(1);
}

2. Not exiting after failed exec:

// WRONG: Child continues as parent!
execvp(cmd, argv);
// If exec fails, continues here...
// RIGHT: Exit on failure
execvp(cmd, argv);
exit(1); // Only reached if exec failed

3. Creating zombie processes:

// WRONG: Not waiting for children
for (i = 0; cmds[i]; i++) {
fork(); // No wait!
}
// RIGHT: Wait for all
for (i = 0; i < cmd_count; i++)
wait(NULL);

4. Parent not closing write end:

// WRONG: Child blocks reading because parent holds write end
// Parent keeps pipefd[1] open -> child's read never gets EOF
// RIGHT: Parent closes write end
close(pipefd[1]);
prev_fd = pipefd[0];
Terminal window
# Compile
gcc -Wall -Wextra -Werror picoshell.c main.c -o picoshell
# Test simple pipeline
./picoshell echo hello "|" cat
# Test multiple pipes
./picoshell ls "|" grep .c "|" wc -l
# Check for fd leaks
lsof -c picoshell
# Check for zombies
ps aux | grep defunct
picoshell.c
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int picoshell(char **cmds[])
{
int i = 0;
int prev_fd = -1;
int pipefd[2];
pid_t pid;
while (cmds[i])
i++;
int cmd_count = i;
for (i = 0; cmds[i]; i++)
{
if (cmds[i + 1] && pipe(pipefd) == -1)
{
if (prev_fd != -1) close(prev_fd);
return 1;
}
pid = fork();
if (pid == -1)
{
if (prev_fd != -1) close(prev_fd);
if (cmds[i + 1]) { close(pipefd[0]); close(pipefd[1]); }
return 1;
}
if (pid == 0)
{
if (prev_fd != -1) { dup2(prev_fd, 0); close(prev_fd); }
if (cmds[i + 1]) { close(pipefd[0]); dup2(pipefd[1], 1); close(pipefd[1]); }
execvp(cmds[i][0], cmds[i]);
exit(1);
}
if (prev_fd != -1) close(prev_fd);
if (cmds[i + 1]) { close(pipefd[1]); prev_fd = pipefd[0]; }
}
for (i = 0; i < cmd_count; i++)
wait(NULL);
return 0;
}

Implement a simplified popen():

  • Take command path, arguments, and mode (‘r’ or ‘w’)
  • Return file descriptor connected to command’s stdin or stdout
  • ‘r’ mode: return fd to read command’s output
  • ‘w’ mode: return fd to write to command’s input

Think before coding:

  1. What does ‘r’ mode mean?

    • “I want to READ the command’s output”
    • Child redirects stdout to pipe write end
    • Parent gets pipe read end
  2. What does ‘w’ mode mean?

    • “I want to WRITE to the command’s input”
    • Child redirects stdin from pipe read end
    • Parent gets pipe write end
  3. Visualization:

    'r' mode: parent <--read-- pipe <--write-- child stdout
    'w' mode: parent --write--> pipe --read--> child stdin

Stage 1: Setup and validation

ft_popen.c
#include <unistd.h>
#include <stdlib.h>
int ft_popen(const char *file, char *const argv[], char type)
{
int pipefd[2];
pid_t pid;
// Validate type
if (type != 'r' && type != 'w')
return -1;
// Create pipe
if (pipe(pipefd) == -1)
return -1;
pid = fork();
if (pid == -1)
{
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
// ... child/parent handling
}

Stage 2: Child process

ft_popen.c - child
if (pid == 0)
{
if (type == 'r')
{
// Parent reads -> child writes to stdout
close(pipefd[0]); // Child won't read
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
}
else // type == 'w'
{
// Parent writes -> child reads from stdin
close(pipefd[1]); // Child won't write
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
}
execvp(file, argv);
exit(1);
}

Stage 3: Parent process

ft_popen.c - parent
// Parent process
if (type == 'r')
{
close(pipefd[1]); // Parent won't write
return pipefd[0]; // Return read end
}
else // type == 'w'
{
close(pipefd[0]); // Parent won't read
return pipefd[1]; // Return write end
}

Mode direction table:

ModeParentChildParent returns
’r’reads from pipewrites to pipe (stdout)pipefd[0]
’w’writes to pipereads from pipe (stdin)pipefd[1]

Memory trick:

  • ‘r’ = “I Read” = parent reads = child outputs = child’s stdout
  • ‘w’ = “I Write” = parent writes = child inputs = child’s stdin

1. Reversed direction:

// WRONG: 'r' mode but returning write end
if (type == 'r')
return pipefd[1]; // Wrong!
// RIGHT: 'r' mode returns read end
if (type == 'r')
return pipefd[0]; // Correct

2. Child redirects wrong fd:

// WRONG: 'r' mode but child redirects stdin
if (type == 'r')
dup2(pipefd[0], STDIN_FILENO); // Wrong!
// RIGHT: 'r' mode, child redirects stdout
if (type == 'r')
dup2(pipefd[1], STDOUT_FILENO); // Correct
ft_popen.c
#include <unistd.h>
#include <stdlib.h>
int ft_popen(const char *file, char *const argv[], char type)
{
int pipefd[2];
pid_t pid;
if (type != 'r' && type != 'w')
return -1;
if (pipe(pipefd) == -1)
return -1;
pid = fork();
if (pid == -1)
{
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
if (pid == 0)
{
if (type == 'r')
{
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
}
else
{
close(pipefd[1]);
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
}
execvp(file, argv);
exit(1);
}
if (type == 'r')
{
close(pipefd[1]);
return pipefd[0];
}
close(pipefd[0]);
return pipefd[1];
}

Test if a function is “nice” or “bad”:

  • Nice: exits with code 0, no signal, no timeout
  • Bad: crashes (signal), exits non-zero, or times out
  • Return 1 for nice, 0 for bad, -1 for sandbox error

Think before coding:

  1. Why fork?

    • Run untrusted function in isolated process
    • If it crashes, parent survives
  2. How to detect problems?

    • WIFEXITED(status) - did it exit normally?
    • WEXITSTATUS(status) - what exit code?
    • WIFSIGNALED(status) - killed by signal?
    • WTERMSIG(status) - which signal?
  3. How to handle timeout?

    • alarm(seconds) sends SIGALRM after delay
    • In handler, set flag
    • After wait, kill child if timed out

Stage 1: Signal handler setup

sandbox.c
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
static volatile sig_atomic_t g_timeout = 0;
static void alarm_handler(int sig)
{
(void)sig;
g_timeout = 1;
}

Stage 2: Main function structure

sandbox.c - main
int sandbox(void (*f)(void), unsigned int timeout, bool verbose)
{
pid_t pid;
int status;
struct sigaction sa;
if (!f)
return -1;
// Setup alarm handler
sa.sa_handler = alarm_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGALRM, &sa, NULL) == -1)
return -1;
g_timeout = 0;
pid = fork();
if (pid == -1)
return -1;
if (pid == 0)
{
f(); // Run the function
exit(0); // Normal exit
}
// Parent continues...
}

Stage 3: Wait with timeout

sandbox.c - wait
// Parent: set timeout and wait
if (timeout > 0)
alarm(timeout);
if (waitpid(pid, &status, 0) == -1)
{
alarm(0);
return -1;
}
alarm(0); // Cancel alarm
// Check timeout
if (g_timeout)
{
kill(pid, SIGKILL);
waitpid(pid, NULL, 0); // Reap killed child
if (verbose)
printf("Bad function: timed out after %u seconds\n", timeout);
return 0;
}

Stage 4: Check exit status

sandbox.c - status check
if (WIFEXITED(status))
{
int code = WEXITSTATUS(status);
if (code == 0)
{
if (verbose)
printf("Nice function!\n");
return 1;
}
if (verbose)
printf("Bad function: exited with code %d\n", code);
return 0;
}
if (WIFSIGNALED(status))
{
int sig = WTERMSIG(status);
if (verbose)
printf("Bad function: %s\n", strsignal(sig));
return 0;
}
return -1;

Status macros:

MacroReturns
WIFEXITED(status)true if process called exit()
WEXITSTATUS(status)exit code (0-255)
WIFSIGNALED(status)true if killed by signal
WTERMSIG(status)signal number that killed it

Signal handling:

// sigaction is safer than signal()
struct sigaction sa;
sa.sa_handler = alarm_handler; // Function to call
sigemptyset(&sa.sa_mask); // No blocked signals
sa.sa_flags = 0; // Default behavior
sigaction(SIGALRM, &sa, NULL); // Install handler

1. Not reaping killed child:

// WRONG: Zombie if timed out
if (g_timeout)
kill(pid, SIGKILL);
return 0;
// RIGHT: Wait after kill
if (g_timeout)
{
kill(pid, SIGKILL);
waitpid(pid, NULL, 0); // Reap zombie
return 0;
}

2. Not canceling alarm:

// WRONG: Alarm fires later, crashes
waitpid(pid, &status, 0);
// ... forgot alarm(0) ...
// RIGHT: Always cancel
waitpid(pid, &status, 0);
alarm(0); // Cancel pending alarm
sandbox.c
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
static volatile sig_atomic_t g_timeout = 0;
static void alarm_handler(int sig) { (void)sig; g_timeout = 1; }
int sandbox(void (*f)(void), unsigned int timeout, bool verbose)
{
pid_t pid;
int status;
struct sigaction sa;
if (!f) return -1;
sa.sa_handler = alarm_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGALRM, &sa, NULL) == -1) return -1;
g_timeout = 0;
pid = fork();
if (pid == -1) return -1;
if (pid == 0) { f(); exit(0); }
if (timeout > 0) alarm(timeout);
if (waitpid(pid, &status, 0) == -1) { alarm(0); return -1; }
alarm(0);
if (g_timeout) {
kill(pid, SIGKILL);
waitpid(pid, NULL, 0);
if (verbose) printf("Bad function: timed out after %u seconds\n", timeout);
return 0;
}
if (WIFEXITED(status)) {
int code = WEXITSTATUS(status);
if (code == 0) { if (verbose) printf("Nice function!\n"); return 1; }
if (verbose) printf("Bad function: exited with code %d\n", code);
return 0;
}
if (WIFSIGNALED(status)) {
if (verbose) printf("Bad function: %s\n", strsignal(WTERMSIG(status)));
return 0;
}
return -1;
}


Evaluate mathematical expressions:

  • Operators: + and *
  • Parentheses: ( and )
  • Numbers: single digits 0-9
  • Operator precedence: * before +

Examples:

3+4*5 = 23 (not 35!)
(3+4)*5 = 35
1+2+3 = 6

Think before coding:

  1. Why does * bind tighter?

    • 3+4*5 should be 3+(4*5) = 23
    • Not (3+4)*5 = 35
  2. Grammar for precedence:

    expr = term (('+') term)* // + is lowest
    term = factor (('*') factor)* // * is higher
    factor = '(' expr ')' | DIGIT // () and numbers highest
  3. Recursive descent:

    • parse_expr handles +
    • parse_term handles *
    • parse_factor handles () and digits
    • Higher precedence = deeper in call stack

Stage 1: Error handling

vbc.c
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
static void error_token(char c)
{
if (c == '\0')
printf("Unexpected end of input\n");
else
printf("Unexpected token '%c'\n", c);
exit(1);
}

Stage 2: Parse factor (highest precedence)

vbc.c - factor
static int parse_expr(const char **s); // Forward declaration
// factor = '(' expr ')' | DIGIT
static int parse_factor(const char **s)
{
if (**s == '(')
{
(*s)++; // Skip '('
int result = parse_expr(s);
if (**s != ')')
error_token(**s);
(*s)++; // Skip ')'
return result;
}
if (isdigit(**s))
{
int num = **s - '0';
(*s)++;
return num;
}
error_token(**s);
return 0; // Never reached
}

Stage 3: Parse term (multiplication)

vbc.c - term
// term = factor (('*') factor)*
static int parse_term(const char **s)
{
int result = parse_factor(s);
while (**s == '*')
{
(*s)++; // Skip '*'
result *= parse_factor(s);
}
return result;
}

Stage 4: Parse expr (addition - lowest precedence)

vbc.c - expr
// expr = term (('+') term)*
static int parse_expr(const char **s)
{
int result = parse_term(s);
while (**s == '+')
{
(*s)++; // Skip '+'
result += parse_term(s);
}
return result;
}

Stage 5: Main function

vbc.c - main
int main(int argc, char **argv)
{
if (argc != 2)
{
printf("Usage: %s 'expression'\n", argv[0]);
return 1;
}
const char *s = argv[1];
int result = parse_expr(&s);
// Check for trailing junk
if (*s != '\0')
error_token(*s);
printf("%d\n", result);
return 0;
}

How precedence works:

Input: "3+4*5"
parse_expr:
parse_term:
parse_factor: 3
(no '*')
result = 3
(sees '+')
(*s)++ (skip '+')
parse_term:
parse_factor: 4
(sees '*')
(*s)++ (skip '*')
parse_factor: 5
4 * 5 = 20
result = 20
3 + 20 = 23
Final: 23

Why pointer to pointer?

// Pass address of pointer so function can advance it
const char *s = "3+4";
parse_expr(&s);
// After parsing, s points past what was consumed

1. Wrong precedence (left-to-right):

// WRONG: Parses left-to-right
int result = parse_factor(s);
while (**s == '+' || **s == '*') {
char op = **s;
(*s)++;
int right = parse_factor(s);
result = (op == '+') ? result + right : result * right;
}
// "3+4*5" = 35 (wrong!)
// RIGHT: Separate levels for each precedence
parse_expr handles +
parse_term handles *
// "3+4*5" = 23 (correct!)

2. Forgetting to check trailing characters:

// WRONG: Accepts "3+4xxx"
result = parse_expr(&s);
printf("%d\n", result);
// RIGHT: Check for junk
result = parse_expr(&s);
if (*s != '\0')
error_token(*s);

3. Multi-digit numbers:

// WRONG: Subject says single digits only
while (isdigit(**s)) {
num = num * 10 + (**s - '0');
(*s)++;
}
// RIGHT: Single digit
int num = **s - '0';
(*s)++;
Terminal window
./vbc '3+4*5' # 23
./vbc '(3+4)*5' # 35
./vbc '1+2+3+4+5' # 15
./vbc '2*3*4' # 24
./vbc '((1+2))' # 3
./vbc '1+2)' # Error: Unexpected token ')'
./vbc '(1+2' # Error: Unexpected end of input
./vbc '1 + 2' # Error: Unexpected token ' '
vbc.c
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
static int parse_expr(const char **s);
static void error_token(char c)
{
if (c == '\0')
printf("Unexpected end of input\n");
else
printf("Unexpected token '%c'\n", c);
exit(1);
}
static int parse_factor(const char **s)
{
if (**s == '(')
{
(*s)++;
int result = parse_expr(s);
if (**s != ')') error_token(**s);
(*s)++;
return result;
}
if (isdigit(**s))
{
int num = **s - '0';
(*s)++;
return num;
}
error_token(**s);
return 0;
}
static int parse_term(const char **s)
{
int result = parse_factor(s);
while (**s == '*')
{
(*s)++;
result *= parse_factor(s);
}
return result;
}
static int parse_expr(const char **s)
{
int result = parse_term(s);
while (**s == '+')
{
(*s)++;
result += parse_term(s);
}
return result;
}
int main(int argc, char **argv)
{
if (argc != 2) { printf("Usage: %s 'expression'\n", argv[0]); return 1; }
const char *s = argv[1];
int result = parse_expr(&s);
if (*s != '\0') error_token(*s);
printf("%d\n", result);
return 0;
}

Parse simplified JSON:

  • Types: numbers, strings, objects (no arrays, booleans, null)
  • No whitespace handling (spaces are errors)
  • Handle escape sequences: \" and \\ only
  • Print exact error messages

Think before coding:

  1. Recursive descent:

    • parse_value dispatches based on first character
    • " → string, digit → number, { → object
  2. Object structure:

    • {key:value,key:value,...}
    • Keys are always strings
    • Values can be any type (including nested objects)
  3. String handling:

    • Read until closing "
    • Handle \" as literal quote, \\ as literal backslash

Stage 1: Data structures

argo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
typedef enum { JSON_NUMBER, JSON_STRING, JSON_OBJECT } json_type;
typedef struct json json;
typedef struct json_pair json_pair;
struct json_pair {
char *key;
json *value;
json_pair *next;
};
struct json {
json_type type;
union {
int number;
char *string;
json_pair *pairs;
};
};

Stage 2: Parse number

argo.c - number
static int parse_number(FILE *f, json *dst)
{
int num;
if (fscanf(f, "%d", &num) != 1)
return -1;
dst->type = JSON_NUMBER;
dst->number = num;
return 1;
}

Stage 3: Parse string

argo.c - string
static int parse_string(FILE *f, json *dst)
{
char buffer[4096];
int i = 0;
int c;
while ((c = getc(f)) != EOF && c != '"')
{
if (c == '\\')
{
c = getc(f);
if (c == '"' || c == '\\')
buffer[i++] = c;
else if (c == EOF)
{
printf("Unexpected end of input\n");
return -1;
}
else
{
printf("Unexpected token '%c'\n", c);
return -1;
}
}
else
{
buffer[i++] = c;
}
}
if (c == EOF)
{
printf("Unexpected end of input\n");
return -1;
}
buffer[i] = '\0';
dst->type = JSON_STRING;
dst->string = strdup(buffer);
return 1;
}

Stage 4: Parse value (dispatcher)

argo.c - value
static int parse_value(FILE *f, json *dst);
static int parse_object(FILE *f, json *dst);
static int parse_value(FILE *f, json *dst)
{
int c = getc(f);
if (c == '"')
return parse_string(f, dst);
if (isdigit(c))
{
ungetc(c, f);
return parse_number(f, dst);
}
if (c == '{')
return parse_object(f, dst);
if (c == EOF)
{
printf("Unexpected end of input\n");
return -1;
}
printf("Unexpected token '%c'\n", c);
return -1;
}

Stage 5: Parse object

argo.c - object
static int parse_object(FILE *f, json *dst)
{
dst->type = JSON_OBJECT;
dst->pairs = NULL;
json_pair **tail = &dst->pairs;
int c = getc(f);
if (c == '}')
return 1; // Empty object
ungetc(c, f);
while (1)
{
c = getc(f);
if (c != '"')
{
if (c == EOF) printf("Unexpected end of input\n");
else printf("Unexpected token '%c'\n", c);
return -1;
}
json key_json;
if (parse_string(f, &key_json) == -1)
return -1;
c = getc(f);
if (c != ':')
{
free(key_json.string);
if (c == EOF) printf("Unexpected end of input\n");
else printf("Unexpected token '%c'\n", c);
return -1;
}
json *value = malloc(sizeof(json));
if (parse_value(f, value) == -1)
{
free(key_json.string);
free(value);
return -1;
}
json_pair *pair = malloc(sizeof(json_pair));
pair->key = key_json.string;
pair->value = value;
pair->next = NULL;
*tail = pair;
tail = &pair->next;
c = getc(f);
if (c == '}') break;
if (c != ',')
{
if (c == EOF) printf("Unexpected end of input\n");
else printf("Unexpected token '%c'\n", c);
return -1;
}
}
return 1;
}
int argo(json *dst, FILE *stream)
{
return parse_value(stream, dst);
}
Terminal window
# Test numbers
echo -n '42' | ./argo /dev/stdin
# Test strings
echo -n '"hello"' | ./argo /dev/stdin
# Test objects
echo -n '{"a":1}' | ./argo /dev/stdin
echo -n '{"name":"test","value":42}' | ./argo /dev/stdin
# Test nested
echo -n '{"outer":{"inner":1}}' | ./argo /dev/stdin
# Test escapes
echo -n '"hello\"world"' | ./argo /dev/stdin
# Test errors
echo -n '{"a":}' | ./argo /dev/stdin # Missing value
echo -n '{a:1}' | ./argo /dev/stdin # Key not string

LevelExerciseKey Concept
1picoshellPipe chains, fork, exec, fd management
1ft_popenPipe direction based on mode
1sandboxSignals, timeout, process status
2vbcRecursive descent, operator precedence
2argoRecursive parsing, escape sequences

Common mistakes to avoid:

  1. Not closing all pipe ends
  2. Not exiting after failed execvp
  3. Wrong pipe direction for read/write mode
  4. Wrong operator precedence in vbc
  5. Wrong error message format (must be exact!)