Accept CAP -- PROGRAM [ARG ...], with CAP from 0 through 65536.
The memory budget must be explicit and validated before work starts.
Check: Malformed, negative, huge, missing, and partially numeric capacities return 2 without a stdout report.
Run one local program, retain a bounded stdout prefix, drain every remaining byte, and explain exactly how the child ended.
Validate the command line and give the buffer one owner.
Replace child stdout with the pipe while preserving stdin and stderr.
Handle empty, exact-limit, over-limit, and newline-free output with bounded memory.
Finish descriptor cleanup and distinguish normal exit from signal termination.
Verify bytes, truncation, stderr policy, and status without assuming scheduling.
Mei checks a sensor report before sharing it with her team. One checker prints a short success message; another prints pages of detail before returning an error. Copying terminal text loses the exit status, and saving every byte wastes memory. She builds a small notebook that keeps the first C bytes, says whether more arrived, and records the child outcome. Her first success is an echo with a space inside one argument. She then faces a full pipe, a missing executable, and a final line without a newline. By the end she can explain every descriptor, every captured byte, and the remaining limits of her tool. This is an original Leon learning project, independent of supplied course assignments.
The memory budget must be explicit and validated before work starts.
Check: Malformed, negative, huge, missing, and partially numeric capacities return 2 without a stdout report.
Joining strings would erase argument boundaries and invite shell interpretation.
Check: The fixture receives two words as one argument and $HOME;* as literal characters.
Stopping at the cap can leave the child blocked on a full pipe.
Check: A 1 MiB burst completes with a cap of 8 or 0, and reports truncation.
A single pipe keeps ownership visible and avoids pretending that two streams have one reliable order.
Check: The stderr fixture produces an ok report and a separate diagnostic.
EOF requires every write end to close; termination still needs waitpid.
Check: Normal exit 0, exit 7, missing program 127, and SIGTERM are reported distinctly.
A captured prefix may contain NUL, controls, or only part of a multibyte character.
Check: NUL, 0xFF, quotes, backslash, tab, carriage return, and newline retain their byte identities.
Parse capacity, allocate the byte buffer, borrow argv, call the process module, escape the retained bytes, and return the child outcome.
Create a pipe and child, redirect child stdout, drain the parent read end, and reap exactly that child. The public result stores a byte count, a truncation flag, and raw wait status.
Generate controlled outputs and outcomes without files, descendants, input, sleeps, or network access. Burst mode writes exactly 256 blocks of 4096 bytes.
Compile a temporary copy and compare real output and return codes with literal expectations. Each runner has an 8-second watchdog and a private process group.
Validate → allocate → pipe → fork. Child: close read end → dup2(write end, 1) → close original write end → execvp. Parent: close write end → read to EOF → close read end → waitpid → escape and report → free. Parent and child run concurrently; there is no chosen schedule. Reads observe bytes already written, EOF follows closure of all writers and draining of queued bytes, and the final report follows a successful wait.
| Resource | Owner | Release |
|---|---|---|
| Pipe read end | Parent after fork; child initially has a copy. | Child closes its copy before exec; parent closes after drain or read error. |
| Original pipe write end | Both processes briefly after fork. | Parent closes immediately. Child closes after dup2 leaves stdout referring to the same pipe. |
| Child stdout, descriptor 1 | Executed program and any descendants that inherit it. | Program close or process termination; every writer must close before EOF. |
| Capture buffer and argument vector | main owns the allocation; process_run borrows it and the argv strings. | main frees the buffer on error or after the report; borrowed argv is never freed here. |
| Direct child and termination status | Parent owns the responsibility to reap its returned PID. | waitpid retries EINTR and consumes termination status; read errors also attempt kill and reap. |
Validate the command line and give the buffer one owner.
Mei chooses a byte budget before running a checker. A bounded decimal parser rejects overflow before multiplying; argv already supplies the required argument boundaries.
Build the complete project with make. Read process.h before following main.
Trace 65536 through parse_capacity, then see why 65537 and 8x are rejected.
Follow malloc, process_run(&argv[3], ...), and free. The process module must not free borrowed memory.
Teaching extract from the complete project. Build the full files below to run it.
static int parse_capacity(const char *text, size_t *capacity)
{
size_t value = 0;
if (*text == '\0') {
return -1;
}
for (const char *p = text; *p != '\0'; ++p) {
if (*p < '0' || *p > '9') {
return -1;
}
size_t digit = (size_t)(*p - '0');
if (value > (PROCESS_MAX_CAPTURE - digit) / 10) {
return -1;
}
value = value * 10 + digit;
}
*capacity = value;
return 0;
}
Extract from main.c, not a standalone program. digit is 0–9. The inequality value <= (MAX-digit)/10 ensures value*10+digit stays within the cap before computing it. Empty strings and any non-digit fail.
It points to the program name followed by its existing arguments and the terminating NULL. Combining them would lose which spaces are inside an argument.
Replace child stdout with the pipe while preserving stdin and stderr.
fork copies descriptors, so both processes initially hold both ends. Descriptor 1 must refer to the pipe in the child before exec changes the running program.
Draw two descriptor tables immediately after fork. Each has a read end and a write end.
In the child, close the read copy, dup2 the write copy onto descriptor 1, then close the original write descriptor.
Call execvp with the unchanged argument vector. On failure, write a fixed stderr diagnostic and call _exit(127).
Teaching extract from the complete project. Build the full files below to run it.
static void execute_child(int read_end, int write_end, char *const argv[])
{
static const char setup_error[] = "process_runner: stdout setup failed\n";
static const char exec_error[] = "process_runner: execvp failed\n";
(void)close(read_end);
int copied;
do {
copied = dup2(write_end, STDOUT_FILENO);
} while (copied == -1 && errno == EINTR);
if (copied == -1) {
(void)close(write_end);
child_failure(setup_error, sizeof(setup_error) - 1, 126);
}
(void)close(write_end);
/* The original argument boundaries survive; no command string is built. */
execvp(argv[0], argv);
child_failure(exec_error, sizeof(exec_error) - 1, 127);
}
Extract from process.c. The caller verifies descriptors 0–2 are open, so the new pipe descriptors exceed 2. dup2 makes stdout another reference to the write end; closing the original does not close stdout. Successful exec never returns. _exit skips copied stdio buffers and atexit callbacks.
No. Descriptor 1 still refers to the pipe after successful dup2. That reference survives exec and closes when the program closes it or exits.
Handle empty, exact-limit, over-limit, and newline-free output with bounded memory.
The pipe has finite kernel capacity too. If Mei stops reading after filling her buffer and then waits, a verbose child may be waiting for pipe space. Both sides would wait forever.
For each positive read, compute space = capacity - captured and keep = min(count, space).
Copy only keep bytes. Set truncated only when some actual bytes do not fit; reaching the cap alone is not truncation.
Continue reading until zero means EOF. Retry EINTR; a final fragment is data even without a newline.
Teaching extract from the complete project. Build the full files below to run it.
static int drain_output(int read_end, unsigned char *buffer, size_t capacity,
struct process_result *result)
{
unsigned char chunk[4096];
for (;;) {
ssize_t received = read(read_end, chunk, sizeof(chunk));
if (received == -1 && errno == EINTR) {
continue;
}
if (received == -1) {
return -1;
}
if (received == 0) {
return 0;
}
size_t count = (size_t)received;
size_t space = capacity - result->captured;
size_t keep = count < space ? count : space;
if (keep > 0) {
memcpy(buffer + result->captured, chunk, keep);
result->captured += keep;
}
if (keep < count) {
result->truncated = true;
}
/* A full buffer stops copying, never reading. The child must progress. */
}
}
Extract from process.c. The invariant is 0 <= captured <= capacity. Each read can be short; one write need not match one read. N bytes of child output require O(N) draining work, while storage is O(C+4096) for cap C. No total-discarded-byte counter can overflow.
Cap 4 retains tail with truncated=no. Cap 3 retains tai with truncated=yes. Cap 0 retains nothing with truncated=yes. All three still drain the same four bytes.
Finish descriptor cleanup and distinguish normal exit from signal termination.
EOF does not prove a child has terminated: it can close stdout and continue running. Conversely, a descendant can hold the pipe open after the direct child ends. Mei needs waitpid as a second completion check.
Close the parent write end before draining; otherwise the parent itself prevents EOF.
After draining, close the read end and wait for the specific child PID. Retry waitpid only when errno is EINTR.
Use wait macros, print the escaped byte prefix and status, then free the buffer. On read failure, kill the direct child and still attempt to reap it.
Teaching extract from the complete project. Build the full files below to run it.
static int report(const unsigned char *data, const struct process_result *result)
{
print_bytes(data, result->captured);
printf("captured_bytes=%zu\ntruncated=%s\n", result->captured,
result->truncated ? "yes" : "no");
if (WIFEXITED(result->wait_status)) {
int code = WEXITSTATUS(result->wait_status);
printf("child_exit=%d\n", code);
return code;
}
if (WIFSIGNALED(result->wait_status)) {
int number = WTERMSIG(result->wait_status);
printf("child_signal=%d\n", number);
return 128 + number;
}
fputs("process_runner: unexpected wait status\n", stderr);
return 125;
}
Extract from main.c. WEXITSTATUS is used only after WIFEXITED; WTERMSIG only after WIFSIGNALED. The raw wait integer is not itself the exit code. Normal exit returns the child code; a signal is displayed separately and maps to 128+signal on supported systems.
No. The selected program could return 127 itself. This project adds a fixed stderr message on exec failure, but has no separate exec-error protocol. An extra close-on-exec error pipe is a possible extension.
Verify bytes, truncation, stderr policy, and status without assuming scheduling.
A real child is a better check than inspecting source text. The synthetic helper produces finite, known behavior, so Mei can compute expectations independently and place a watchdog around the whole run.
Run make test. It builds a temporary copy with strict warnings and invokes only the local fixture, /bin/echo, and a deliberately missing path.
Compare exact output, byte counts, child status, and separate stderr. The burst is 256*4096=1048576 bytes regardless of read chunk sizes.
Test zero, exact and over-limit caps, literal metacharacters, nonzero exit, signal exit, binary escapes, and missing programs. Timeouts clean up only the private test process group.
Teaching extract from the complete project. Build the full files below to run it.
int main(int argc, char **argv)
{
if (argc < 2) {
return 2;
}
if (strcmp(argv[1], "args") == 0) {
for (int i = 2; i < argc; ++i) {
printf("[%s]", argv[i]);
}
} else if (strcmp(argv[1], "burst") == 0) {
char block[4096];
memset(block, 'A', sizeof(block));
for (int i = 0; i < 256; ++i) {
if (fwrite(block, 1, sizeof(block), stdout) != sizeof(block)) {
return 3;
}
}
} else if (strcmp(argv[1], "fragment") == 0) {
fputs("tail", stdout);
} else if (strcmp(argv[1], "fail") == 0) {
puts("check failed");
return 7;
} else if (strcmp(argv[1], "stderr") == 0) {
fputs("diagnostic\n", stderr);
puts("ok");
} else if (strcmp(argv[1], "bytes") == 0) {
const unsigned char bytes[] = {0, 255, '"', '\\', '\t', '\r', '\n'};
if (fwrite(bytes, 1, sizeof(bytes), stdout) != sizeof(bytes)) {
return 3;
}
} else if (strcmp(argv[1], "signal") == 0) {
(void)raise(SIGTERM);
return 3;
} else if (strcmp(argv[1], "empty") != 0) {
return 2;
}
return fflush(stdout) == 0 ? 0 : 3;
}
Extract from fixtures/child.c, a separate bounded executable used by the tests. The helper neither forks nor reads stdin. Its burst is finite, its fail mode returns 7, and fragment omits the newline. These examples test this project and are not supplied assignment solutions.
It shows that this run drained a finite large stream while retaining only the cap and reaped the child. It does not prove every schedule, forced EINTR/error path, descendant cleanup, or a production timeout policy.
Use Clang with C11 support, make and Python 3 on macOS or Linux. On Windows use a Linux environment such as WSL. The supplied Makefiles use Clang. Open a terminal in the download directory. Unzip the archive, then enter its folder. make builds the executable; make test checks its behavior.
unzip comp2017-process-runner.zip
cd comp2017-process-runner
make
make test
./process_runner 32 -- /bin/echo 'hello team'
stdout="hello team\n" captured_bytes=11 truncated=no child_exit=0
Exit status: 0.
The shell quotes create one hello team argument before the runner starts. The child writes 11 bytes including the newline. The runner returns 0 after EOF and waitpid.
./process_runner 8 -- ./fixture_child burst
stdout="AAAAAAAA" captured_bytes=8 truncated=yes child_exit=0
Exit status: 0.
The helper writes 1,048,576 A bytes. Only eight are retained, but all remaining output is drained so the child can finish. Truncation does not make the child fail: the runner returns 0.
./process_runner 32 -- ./fixture_child fail
stdout="check failed\n" captured_bytes=13 truncated=no child_exit=7
Exit status: 7.
The 13-byte message is complete, yet the child exits with 7. The runner returns 7 too. Successful capture and a successful child are different claims.
./process_runner 16 -- ./absent-program
stdout="" captured_bytes=0 truncated=no child_exit=127
Exit status: 127.
The child writes process_runner: execvp failed plus a newline to inherited stderr (not shown in the stdout block), then calls _exit(127). The parent sees EOF, reaps it, and returns 127. A real program could also return 127, so the status alone is ambiguous.
./process_runner 4 -- ./fixture_child fragment
stdout="tail" captured_bytes=4 truncated=no child_exit=0
Exit status: 0.
tail is exactly four bytes. No byte is discarded, so truncated=no even though capacity is full. A newline is not needed to finish a byte stream. The runner returns 0.
./process_runner 64 -- ./fixture_child args 'two words' '$HOME;*'
stdout="[two words][$HOME;*]" captured_bytes=20 truncated=no child_exit=0
Exit status: 0.
The fixture surrounds each argument with brackets. The 20-byte result shows that two words stayed together and $HOME;* stayed literal. The runner returns 0; it never formed a command string.
The parent waited before draining, or stopped reading at the capture limit.
Run the finite burst with cap 8 under make test; inspect whether every positive read leads back to read.
Drain to EOF even when keep becomes zero; only then call waitpid.
The parent retained its write end, or another process inherited a writer.
Draw every write descriptor in both processes; check the parent close immediately after fork.
Close the unused parent write end. A descendant-held writer requires a future process-tree policy.
The code treats full capacity as proof of discarded data.
Compare fragment at cap 4 and cap 3; only the latter loses a byte.
Set truncated only when keep < count for an actual positive read.
The code printed raw wait status as the exit code.
Run the fail fixture and inspect the WIFEXITED guard before WEXITSTATUS.
Decode with the wait macros; check signal termination in a separate branch.
A string routine such as strlen was used on raw captured bytes.
Run the bytes fixture: all seven bytes must remain visible as characters or escapes.
Iterate to result.captured, use unsigned char, and escape bytes individually.
The invocation was not quoted at the outer shell, or a command string was introduced.
Use the args fixture and single-quote the literal values in a terminal command.
Preserve the received argv vector in the runner; use shell quoting only to construct the initial arguments.
echo prints hello team plus newline: 11 bytes, exit 0.
Detects accidental splitting and newline loss.
The args fixture prints [two words][$HOME;*]: 20 bytes.
Detects command-string construction or expansion.
Keep exactly the cap, report truncated=yes, and exit 0 before the watchdog.
Tests progress after capacity is exhausted, including the two limit endpoints.
Keep zero bytes with truncated=no, exit 0.
A zero-size budget is not itself evidence of lost data.
Retain tail/no and tai/yes respectively, without requiring a newline.
Separates exact capacity from overflow and byte streams from lines.
Capture check failed plus newline, print child_exit=7, return 7.
Runner success must not erase the child failure status.
Empty capture, fixed execvp stderr diagnostic, child_exit=127, return 127.
Exercises the child _exit path and parent EOF/wait lifecycle.
stderr stays separate; the bytes fixture reports all seven bytes with reversible escapes.
Prevents stream mixing, NUL truncation, and raw terminal-control output.
Report child_signal using the platform signal number; return 128 plus that number.
Exercises a different wait-status branch without timing assumptions.
Return 2 with usage on stderr and no stdout report.
Checks rejection before allocation/fork and guards numeric overflow.
Stored bytes=min(N,C); truncated=(N>C). Draining remains O(N) and memory O(C+4096). No timeout or process-tree control exists. A child waiting on input, a descendant keeping stdout open, a blocked stderr sink, or a child that never exits can stall the run. The test watchdog belongs to test.py, not the runner.
stdout is captured and displayed after completion. stdin and stderr are inherited, and stderr can appear before the later stdout report without defining a merged order. Bytes use ASCII escapes rather than Unicode decoding; a cap may split a UTF-8 sequence, but its retained bytes remain visible. This is not a raw binary-copy tool.
argv boundaries remain intact. A bare program name uses inherited PATH; an explicit path selects more predictably. POSIX execvp can invoke an interpreter for an executable text file in an unrecognized format. That fallback interprets the file, not a newly joined argument string. The program inherits authority, directory, environment, and unrelated non-close-on-exec descriptors; it is not a sandbox.
Normal child exit codes pass through. Signals are displayed separately and map to 128+N. CLI errors return 2, handled runner errors 125, child setup errors 126, and exec failures 127. A child can return any of those codes itself. No exec-error pipe exists, so 127 alone cannot identify exec failure. Fixed child diagnostics remain separate on stderr.
The module assumes one thread, open descriptors 0–2, blocking I/O, default SIGCHLD handling, and no competing reaper. read/waitpid/dup2/child diagnostic write retry EINTR. On read error, cleanup kills only the direct child and attempts to reap it. close is best effort without retry; cross-platform recovery from interrupted close is omitted. There is no application signal handler, forwarding, or user-cancel policy. An interrupted runner can leave a child; a broken report pipe can cause default SIGPIPE termination.
These are the complete files in the download. The numbers suggest a reading order. The file list links directly to each source block.
English build and engineering guide
Keep these file names and folders when building. Long lines scroll within the code panel.
# Project 2: A bounded process notebook
Leon | Original teaching project.
Mei runs a small local check before sharing her team's sensor report. She needs
three facts: what the check printed, whether she kept all of it, and how it ended.
Copying a command into a shell would hide the process and pipe rules she wants to
learn. This project builds a command-line notebook for one authorized program.
This is an original learning project, not a supplied course assignment or a
production job supervisor. Prerequisites: argument arrays, allocation ownership,
separate compilation, fork, exec/wait, and pipes.
## Build, run, and test
Use macOS or Linux with Clang, Make, and Python 3. Run from this directory:
```sh
make
./process_runner 32 -- /bin/echo 'hello team'
./process_runner 8 -- ./fixture_child burst
./process_runner 32 -- ./fixture_child fail
./process_runner 16 -- ./absent-program
make test
make clean
```
`make` uses `clang -std=c11 -Wall -Wextra -Werror -pedantic -pthread` and
`-D_POSIX_C_SOURCE=200809L`. The code is single-threaded; `-pthread` keeps the
course project build convention consistent. `make test` builds another copy in a
temporary directory and removes it afterward. It does not require `make` first.
The final two runner commands intentionally return 7 and 127, respectively.
Run the commands individually if your terminal script stops on a nonzero status.
The interface is `process_runner CAP -- PROGRAM [ARG ...]`. CAP is decimal digits
from 0 through 65536. A name without `/` is searched using inherited PATH. Prefer
an explicit path when choosing a specific executable. Shell quotes in the
examples make one argument before the runner starts; the runner passes the
existing argument vector to `execvp`. It does not join arguments, expand `$HOME`,
expand `*`, split spaces, or interpret `;`.
For the first command, standard output is exactly:
```text
stdout="hello team\n"
captured_bytes=11
truncated=no
child_exit=0
```
For the burst command, the fixture writes 1,048,576 `A` bytes and the report is:
```text
stdout="AAAAAAAA"
captured_bytes=8
truncated=yes
child_exit=0
```
For the failing check, standard output is:
```text
stdout="check failed\n"
captured_bytes=13
truncated=no
child_exit=7
```
For the missing program, standard output is:
```text
stdout=""
captured_bytes=0
truncated=no
child_exit=127
```
It also writes `process_runner: execvp failed` followed by a newline to stderr.
The failure did not strand the child: the parent reached EOF and reaped it.
## Five connected milestones
1. **Specify the notebook.** Read `main.c` and `process.h`. Validate the cap before
allocating or forking. Borrow `&argv[3]` without building a command string.
The caller owns the allocated buffer; the process module only writes into it.
2. **Give the child a channel.** Read `execute_child` in `process.c`. `pipe` creates
a read end and a write end. After `fork`, each process has descriptors for both.
The child closes its read end, duplicates its write end onto stdout (fd 1),
closes the original write descriptor, then calls `execvp`.
3. **Keep memory bounded without blocking progress.** Read `drain_output`.
Each read may return any positive number up to 4096. Save only what fits,
but read again even when the capture is full. CAP=0 discards everything while
still letting the child finish. The final `tail` fixture needs no newline.
4. **Finish the lifecycle.** Read `process_run` and `report`. The parent closes
its write end immediately. It drains until EOF, closes its read end, and calls
`waitpid` for this child. Decode status with `WIFEXITED`/`WEXITSTATUS` or
`WIFSIGNALED`/`WTERMSIG`, never by guessing bits in the raw status integer.
5. **Prove the boundaries.** Read `fixtures/child.c` and `test.py`. Use literal
expected byte counts, return codes, stdout, and stderr. Try a cap of 3, 4, and
0 for a four-byte fragment. Then test a much larger burst and a missing program.
These are concept checks for this project, not answers to another assignment.
## Descriptor ownership and ordering
| Resource | Owner after setup | Release |
| --- | --- | --- |
| `channel[0]`, read descriptor | Parent | Close after EOF or read failure; child closes its copy before exec |
| `channel[1]`, original write descriptor | Neither after setup | Parent closes immediately; child closes after `dup2` |
| Child stdout, descriptor 1 | Executed child | Child closes it or process termination closes it |
| Capture buffer | `main` in parent | `free` on process failure or after the report |
| Direct child PID/status | Parent | `waitpid(child, ..., 0)` consumes its termination status |
| Argument strings | C runtime/caller | Borrowed, never freed or rewritten by this module |
An empty pipe gives EOF only after **all** write descriptors referring to it have
closed. Closing the parent's copy is essential. A grandchild could keep stdout
open, delaying EOF even after the direct child exits. EOF and child termination
are separate events: a child could close stdout and keep running. Waiting after
the drain is therefore still necessary. A successful wait tells us the direct
child terminated; it does not wait for its descendants.
There is no chosen scheduling order between parent and child. A successful read
returns bytes already written into the pipe. The final report happens after the
drain and successful wait. If the parent waited first, a full pipe could block the
child's write, while the parent waits for that same child to finish. Keeping only
a small prefix does not justify stopping the drain.
For N output bytes and cap C, stored bytes = min(N, C), truncation = (N > C).
The algorithm reads O(N) bytes and uses O(C + 4096) memory. It deliberately does
not count all discarded bytes, avoiding an unbounded total-byte counter. The
report can contain up to four characters per stored byte because of escaping.
## Output, errors, and boundaries
- Only stdout is captured. Stdin and stderr are inherited. Diagnostics go directly
to the existing stderr sink; their timing relative to the later stdout report
is not specified. Captured bytes are displayed as printable ASCII with `\n`,
`\r`, `\t`, `\\`, `\"`, and two-digit `\xHH` escapes. NUL and high bytes are
supported. This is a byte view, not decoded Unicode or a raw binary copy.
- Normal child exit codes are returned unchanged. Signal termination is printed
as `child_signal=N` and mapped to `128+N` on the supported systems. The report
distinguishes a signal from an ordinary child exit of the same numeric code.
- Usage errors return 2. Runner allocation/setup/read/wait/report errors return
125 when handled. Child redirection failure uses `_exit(126)`; exec failure
writes a fixed diagnostic and uses `_exit(127)`. A program can itself return
126 or 127; there is no separate exec-error pipe to disambiguate them. A runner
error has no complete child report. OS error wording from `perror` can vary.
- `read`, `waitpid`, child diagnostic `write`, and `dup2` retry `EINTR`. On a read
failure the parent sends SIGKILL to its own direct child, closes the read end,
and still attempts to reap it. A failed `fork` closes both descriptors while
preserving errno. The small child path uses `_exit`, so it does not flush a
copied parent stdio buffer or call inherited `atexit` handlers.
- The API assumes a single-threaded caller, open standard descriptors, ordinary
blocking I/O, no competing child reaper, and default SIGCHLD handling. It checks
descriptors 0, 1, and 2 before creating the pipe so pipe descriptors exceed 2.
Cleanup closes are best effort and are not retried; portable recovery from
interrupted `close` across every POSIX variant is outside this example.
- This is not a sandbox. The program inherits the current directory, environment,
stdin, stderr, and other non-close-on-exec descriptors. POSIX `execvp` can invoke
a command interpreter for an executable text file with an unrecognized format.
This fallback interprets the selected file; the runner still never constructs
a shell command from its arguments. Use the supplied compiled fixture or an
executable you are authorized to run.
- There is no deadline, process-tree cleanup, resource quota, or signal-forwarding
policy. A child waiting for input, a descendant holding the pipe open, a blocked
stderr sink, or a child that never exits can stall the runner. Interrupting the
runner may leave a child alive; no application signal handlers are installed.
A broken report pipe can terminate it by default SIGPIPE. Adding cancellation
correctly requires a wider design than one extra `kill` call.
The tests launch only the finite fixture helper, `/bin/echo`, and one deliberately
missing path. Each runner gets an isolated process group and an 8-second test
watchdog; timeout cleanup targets that test group. The fixture creates no
children, reads no input, and writes at most 1 MiB. A 30-second build timeout
bounds compilation. Fourteen tests cover malformed caps, spaces and metacharacters,
empty output, exact/over limits, binary escaping, stderr, normal/nonzero/signal
status, and exec failure. They do not force rare allocation/fork/read failures,
EINTR delivery, every schedule, or process-tree cancellation.
## Primary references
- [POSIX fork](https://pubs.opengroup.org/onlinepubs/9799919799/functions/fork.html)
- [POSIX exec family](https://pubs.opengroup.org/onlinepubs/9799919799/functions/exec.html)
- [POSIX pipe](https://pubs.opengroup.org/onlinepubs/9799919799/functions/pipe.html)
- [POSIX read](https://pubs.opengroup.org/onlinepubs/9799919799/functions/read.html)
- [POSIX wait and waitpid](https://pubs.opengroup.org/onlinepubs/9699919799/functions/wait.html)
- [POSIX dup2](https://pubs.opengroup.org/onlinepubs/9799919799/functions/dup.html)
- [POSIX _exit](https://pubs.opengroup.org/onlinepubs/9799919799/functions/_exit.html)
Start with the four real command examples and their exit semantics.
Follow the five milestones and descriptor ownership table.
Read the limits before using a program outside the finite fixtures.
Complete Chinese companion guide
Keep these file names and folders when building. Long lines scroll within the code panel.
# 项目 2:有容量上限的进程记录器
Leon | Original teaching project. Leon 原创教学项目。
小梅在分享团队的传感器报告前,会运行一个本地检查程序。她需要知道三件事:
程序输出了什么、是否保留了全部输出,以及程序怎样结束。为了理解进程和管道,
她要亲手实现一个命令行记录器,每次运行一个她有权运行的程序。
这是原创学习项目,不是其他课程的作业答案,也不是生产级任务管理器。预备知识:
参数数组、内存所有权、分离编译、fork、exec/wait 和管道。
## 编译、运行和测试
需要 macOS 或 Linux,以及 Clang、Make 和 Python 3。在本目录运行:
```sh
make
./process_runner 32 -- /bin/echo 'hello team'
./process_runner 8 -- ./fixture_child burst
./process_runner 32 -- ./fixture_child fail
./process_runner 16 -- ./absent-program
make test
make clean
```
编译选项为 `clang -std=c11 -Wall -Wextra -Werror -pedantic -pthread`,并设置
`-D_POSIX_C_SOURCE=200809L`。程序是单线程的;`-pthread` 用来统一课程项目的编译要求。
`make test` 会在临时目录编译副本,测试结束后删除副本。测试前不必先运行 `make`。
最后两个运行命令故意返回 7 和 127。如果你的脚本遇到非零状态就停止,请逐条运行。
接口是 `process_runner CAP -- PROGRAM [ARG ...]`。CAP 只能包含十进制数字,范围是
0 到 65536。不含 `/` 的程序名由继承的 PATH 查找。要指定某个可执行文件时,建议
明确写出路径。示例的引号由启动记录器的 shell 处理,让 `hello team` 成为一个参数。
记录器把已有参数数组直接传给 `execvp`,不会拼接命令,不会展开 `$HOME` 或 `*`,
不会按空格拆分参数,也不会把 `;` 当成命令分隔符。
第一个命令的标准输出严格如下:
```text
stdout="hello team\n"
captured_bytes=11
truncated=no
child_exit=0
```
burst 测试程序写出 1,048,576 个 `A`,报告只保留前 8 个:
```text
stdout="AAAAAAAA"
captured_bytes=8
truncated=yes
child_exit=0
```
失败检查的标准输出如下:
```text
stdout="check failed\n"
captured_bytes=13
truncated=no
child_exit=7
```
找不到程序时,标准输出如下:
```text
stdout=""
captured_bytes=0
truncated=no
child_exit=127
```
标准错误还会收到 `process_runner: execvp failed` 和一个换行。父进程仍然读到 EOF,
并取得子进程的结束状态。执行失败不等于放弃回收子进程。
## 五个连续里程碑
1. **定义记录器的接口。** 阅读 `main.c` 和 `process.h`。先检查容量,再分配内存和
创建进程。直接借用 `&argv[3]`,不拼接命令字符串。main 拥有缓冲区,进程模块只是写入它。
2. **给子进程一条输出通道。** 阅读 `process.c` 中的 `execute_child`。pipe 建立读端和
写端。fork 后父子进程各有这两个描述符的副本。子进程关闭读端,用 dup2 把写端复制到
标准输出 1,关闭原来的写端描述符,再调用 execvp。
3. **限制内存,但继续读取。** 阅读 `drain_output`。每次 read 可能返回 1 到 4096 个
字节。只复制放得下的部分,但缓冲区满了也要继续读。CAP=0 时不保存输出,仍让子进程
顺利写完。fragment 模式输出 `tail`,没有换行也能完整处理。
4. **完成进程生命周期。** 阅读 `process_run` 和 `report`。父进程立即关闭自己的写端,
读到 EOF 后关闭读端,再用 waitpid 等待这个子进程。用 WIFEXITED/WEXITSTATUS 或
WIFSIGNALED/WTERMSIG 解码结果,不要猜原始状态整数里各个位的含义。
5. **检查边界。** 阅读 `fixtures/child.c` 和 `test.py`。用手工算出的字节数、状态码、
标准输出和标准错误作为预期结果。对四字节输出尝试容量 3、4 和 0,再测试大量输出和
不存在的程序。这些是本项目的概念检查,不是其他课程的作业答案。
## 描述符所有权与执行顺序
| 资源 | 初始化后由谁持有 | 如何释放 |
| --- | --- | --- |
| `channel[0]` 读端 | 父进程 | EOF 或读取失败后关闭;子进程在 exec 前关闭自己的副本 |
| `channel[1]` 原写端描述符 | 初始化后双方都不再保留 | 父进程立即关闭;子进程 dup2 后关闭 |
| 子进程的标准输出 1 | 新执行的程序 | 程序主动关闭或进程结束时关闭 |
| 保存输出的缓冲区 | 父进程的 main | 运行失败或报告完成后 free |
| 直接子进程的 PID 与结束状态 | 父进程 | waitpid 取得结束状态并完成回收 |
| 参数字符串 | C 运行时或调用者 | 本模块只借用,不释放、不改写 |
管道已经读空,并且所有写端描述符都关闭后,read 才返回 EOF。父进程必须关闭自己的
写端。孙进程若继承了标准输出,即使直接子进程已经结束,也可能推迟 EOF。反过来,
子进程可以先关闭标准输出,再继续计算。所以 EOF 和子进程结束是两个不同事件。
读取结束后仍需 waitpid;它只等待直接子进程,不等待整棵后代进程树。
父子进程没有固定调度顺序。read 返回的是已经写入管道的字节。最终报告发生在读完和
等待成功之后。如果先 wait,子进程可能因管道已满而卡在 write,父进程又在等它结束,
双方就无法前进。只保存一个小前缀,并不能成为停止读取的理由。
设输出总量为 N,容量为 C,则保存字节数为 min(N,C),截断条件为 N>C。读取工作量为
O(N),内存为 O(C+4096)。程序不累加已经丢弃的总字节数,因此不需要处理无限增长的
总量计数器。一个字节转义后最多用四个字符显示,所以显示长度不等于保存字节数。
## 输出、错误与适用范围
- 只捕获 stdout。stdin 和 stderr 保持继承。诊断直接送往原来的 stderr,不能假定它与
最后 stdout 报告的显示先后。保存的字节使用可打印 ASCII 和 `\n`、`\r`、`\t`、
`\\`、`\"`、两位十六进制 `\xHH` 显示。支持 NUL 和高位字节;这是字节视图,
不是 Unicode 解码器,也不是原始二进制文件副本。
- 正常结束时,记录器原样返回子进程的退出码。信号结束时显示 `child_signal=N`,在
支持的平台上返回 `128+N`。报告可以区分信号结束和恰好返回相同数字的普通退出。
- 参数错误返回 2;能够处理的记录器分配、创建、读取、等待或报告错误返回 125。
子进程重定向失败调用 `_exit(126)`,exec 失败写固定诊断后调用 `_exit(127)`。
程序本身也可以返回 126 或 127;本项目没有额外的 exec 错误管道来消除歧义。
记录器错误没有完整的子进程报告。perror 的系统错误文字可能因平台或语言环境而不同。
- read、waitpid、子进程诊断 write 和 dup2 遇到 EINTR 会重试。读取发生其他错误时,
父进程向自己的直接子进程发送 SIGKILL,关闭读端,并继续尝试 waitpid。
fork 失败时关闭两个描述符,并保存原始 errno。子进程用 `_exit`,避免刷新继承的
父进程 stdio 缓冲区,也不会执行父进程注册的 atexit 回调。
- API 假设调用者是单线程、标准描述符已经打开、使用普通阻塞 I/O、没有其他回收者,
并保留默认 SIGCHLD 处理。程序先检查 0、1、2,确保新管道描述符大于 2,简化 dup2
和 close 的所有权关系。清理时尽力关闭,不重试 close;兼容所有 POSIX 变体的
close 中断恢复不在这个例子的范围内。
- 这不是沙箱。子进程继承工作目录、环境、stdin、stderr,以及没有设置 close-on-exec
的其他描述符。按 POSIX 规则,execvp 遇到格式无法识别的可执行文本文件时,可能调用
命令解释器来执行该文件。被解释的是选中的文件;记录器仍不把参数拼成 shell 命令。
练习时使用随附的编译后测试程序,或你有权执行的本地程序。
- 没有超时、进程树清理、资源配额或信号转发策略。子进程等待输入、后代保留写端、stderr
接收方阻塞或子进程一直不结束,都可能让记录器等待。记录器没有安装信号处理器,
中断它可能留下仍存活的子进程。报告管道断开时,默认 SIGPIPE 可能直接结束记录器。
完整取消机制需要更大的设计,不能只加一次 kill 就声称完成。
测试只启动有限的本地 fixture_child、`/bin/echo`,以及一个故意不存在的路径。
每次测试创建独立进程组,8 秒内未结束就清理该测试进程组。测试辅助程序不创建后代、
不读取输入,最多写出 1 MiB。编译有 30 秒超时。14 个测试覆盖错误容量、空格和特殊字符、
空输出、恰好达到与超过容量、二进制转义、stderr、普通退出、非零退出、信号退出和 exec
失败。它们不能强制覆盖罕见分配/fork/read 失败、EINTR、所有调度或进程树取消。
## 官方参考
- [POSIX fork](https://pubs.opengroup.org/onlinepubs/9799919799/functions/fork.html)
- [POSIX exec 系列](https://pubs.opengroup.org/onlinepubs/9799919799/functions/exec.html)
- [POSIX pipe](https://pubs.opengroup.org/onlinepubs/9799919799/functions/pipe.html)
- [POSIX read](https://pubs.opengroup.org/onlinepubs/9799919799/functions/read.html)
- [POSIX wait 与 waitpid](https://pubs.opengroup.org/onlinepubs/9699919799/functions/wait.html)
- [POSIX dup2](https://pubs.opengroup.org/onlinepubs/9799919799/functions/dup.html)
- [POSIX _exit](https://pubs.opengroup.org/onlinepubs/9799919799/functions/_exit.html)
Use the same build, run, test, and clean commands.
Trace EOF and wait as two distinct conditions.
Check the Chinese explanation of signals, exec fallback, and inherited resources.
Public data and ownership contract
Keep these file names and folders when building. Long lines scroll within the code panel.
/* Leon | Original teaching project. */
#ifndef LEON_PROCESS_H
#define LEON_PROCESS_H
#include <stdbool.h>
#include <stddef.h>
#define PROCESS_MAX_CAPTURE 65536U
struct process_result {
size_t captured;
bool truncated;
int wait_status;
};
/* Synchronous, single-threaded caller; standard descriptors 0, 1, 2 must be open.
* argv is a nonempty, NULL-terminated argument vector, borrowed for this call.
* buffer belongs to the caller and holds at least capacity bytes (0 is valid).
* Return 0 after reaping the direct child, even if it failed. On -1, errno
* describes a runner error; do not consume result. No timeout is provided. */
int process_run(char *const argv[], unsigned char *buffer, size_t capacity,
struct process_result *result);
#endif
Read the cap limit and three fields in process_result.
Observe that the buffer and argv are borrowed rather than transferred.
Return 0 means the child was reaped, even when its own exit was nonzero.
CLI validation, byte display, and exit-code mapping
Keep these file names and folders when building. Long lines scroll within the code panel.
/* Leon | Original teaching project. */
#include "process.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
static int parse_capacity(const char *text, size_t *capacity)
{
size_t value = 0;
if (*text == '\0') {
return -1;
}
for (const char *p = text; *p != '\0'; ++p) {
if (*p < '0' || *p > '9') {
return -1;
}
size_t digit = (size_t)(*p - '0');
if (value > (PROCESS_MAX_CAPTURE - digit) / 10) {
return -1;
}
value = value * 10 + digit;
}
*capacity = value;
return 0;
}
/* A byte view: no strlen on captured data, and no raw terminal controls. */
static void print_bytes(const unsigned char *data, size_t length)
{
fputs("stdout=\"", stdout);
for (size_t i = 0; i < length; ++i) {
unsigned char byte = data[i];
switch (byte) {
case '\n': fputs("\\n", stdout); break;
case '\r': fputs("\\r", stdout); break;
case '\t': fputs("\\t", stdout); break;
case '\\': fputs("\\\\", stdout); break;
case '"': fputs("\\\"", stdout); break;
default:
if (byte >= 32 && byte <= 126) {
putchar((int)byte);
} else {
printf("\\x%02X", (unsigned int)byte);
}
}
}
puts("\"");
}
static int report(const unsigned char *data, const struct process_result *result)
{
print_bytes(data, result->captured);
printf("captured_bytes=%zu\ntruncated=%s\n", result->captured,
result->truncated ? "yes" : "no");
if (WIFEXITED(result->wait_status)) {
int code = WEXITSTATUS(result->wait_status);
printf("child_exit=%d\n", code);
return code;
}
if (WIFSIGNALED(result->wait_status)) {
int number = WTERMSIG(result->wait_status);
printf("child_signal=%d\n", number);
return 128 + number;
}
fputs("process_runner: unexpected wait status\n", stderr);
return 125;
}
int main(int argc, char **argv)
{
size_t capacity;
if (argc < 4 || strcmp(argv[2], "--") != 0 || argv[3][0] == '\0' ||
parse_capacity(argv[1], &capacity) == -1) {
fputs("usage: process_runner CAP -- PROGRAM [ARG ...]\n"
"CAP must be decimal digits from 0 to 65536.\n", stderr);
return 2;
}
unsigned char *data = malloc(capacity > 0 ? capacity : 1);
if (data == NULL) {
fputs("process_runner: allocation failed\n", stderr);
return 125;
}
struct process_result result;
if (process_run(&argv[3], data, capacity, &result) == -1) {
perror("process_runner");
free(data);
return 125;
}
int code = report(data, &result);
free(data);
if (fflush(stdout) == EOF || ferror(stdout)) {
fputs("process_runner: report write failed\n", stderr);
return 125;
}
return code;
}
parse_capacity rejects malformed and out-of-range input before allocation.
print_bytes uses a byte count rather than strlen, preserving embedded NUL.
report decodes wait status; main frees the allocation on handled paths and checks the final stdout flush.
Pipe setup, fork/exec, continuous drain, and wait
Keep these file names and folders when building. Long lines scroll within the code panel.
/* Leon | Original teaching project. */
#include "process.h"
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
/* Child failure: no stdio flushing or parent atexit handlers after fork. */
static void child_failure(const char *message, size_t length, int code)
{
while (length > 0) {
ssize_t sent = write(STDERR_FILENO, message, length);
if (sent < 0 && errno == EINTR) {
continue;
}
if (sent <= 0) {
break;
}
message += (size_t)sent;
length -= (size_t)sent;
}
_exit(code);
}
static void execute_child(int read_end, int write_end, char *const argv[])
{
static const char setup_error[] = "process_runner: stdout setup failed\n";
static const char exec_error[] = "process_runner: execvp failed\n";
(void)close(read_end);
int copied;
do {
copied = dup2(write_end, STDOUT_FILENO);
} while (copied == -1 && errno == EINTR);
if (copied == -1) {
(void)close(write_end);
child_failure(setup_error, sizeof(setup_error) - 1, 126);
}
(void)close(write_end);
/* The original argument boundaries survive; no command string is built. */
execvp(argv[0], argv);
child_failure(exec_error, sizeof(exec_error) - 1, 127);
}
static int drain_output(int read_end, unsigned char *buffer, size_t capacity,
struct process_result *result)
{
unsigned char chunk[4096];
for (;;) {
ssize_t received = read(read_end, chunk, sizeof(chunk));
if (received == -1 && errno == EINTR) {
continue;
}
if (received == -1) {
return -1;
}
if (received == 0) {
return 0;
}
size_t count = (size_t)received;
size_t space = capacity - result->captured;
size_t keep = count < space ? count : space;
if (keep > 0) {
memcpy(buffer + result->captured, chunk, keep);
result->captured += keep;
}
if (keep < count) {
result->truncated = true;
}
/* A full buffer stops copying, never reading. The child must progress. */
}
}
int process_run(char *const argv[], unsigned char *buffer, size_t capacity,
struct process_result *result)
{
if (argv == NULL || argv[0] == NULL || argv[0][0] == '\0' ||
result == NULL || capacity > PROCESS_MAX_CAPTURE ||
(capacity > 0 && buffer == NULL)) {
errno = EINVAL;
return -1;
}
/* Keeps both pipe descriptors above 2, so dup2/close ownership is simple. */
for (int fd = STDIN_FILENO; fd <= STDERR_FILENO; ++fd) {
if (fcntl(fd, F_GETFD) == -1) {
return -1;
}
}
*result = (struct process_result){0, false, 0};
int channel[2];
if (pipe(channel) == -1) {
return -1;
}
pid_t child = fork();
if (child == -1) {
int saved = errno;
(void)close(channel[0]);
(void)close(channel[1]);
errno = saved;
return -1;
}
if (child == 0) {
execute_child(channel[0], channel[1], argv);
}
/* Parent owns only the read end. Keeping any write end would prevent EOF. */
(void)close(channel[1]);
int failure = 0;
if (drain_output(channel[0], buffer, capacity, result) == -1) {
failure = errno;
/* On a read error, abort our direct child before trying to reap it. */
(void)kill(child, SIGKILL);
}
(void)close(channel[0]);
pid_t waited;
do {
waited = waitpid(child, &result->wait_status, 0);
} while (waited == -1 && errno == EINTR);
if (waited == -1 && failure == 0) {
failure = errno;
}
if (failure != 0) {
errno = failure;
return -1;
}
return 0;
}
Check standard descriptors, then create the pipe before fork so both processes can refer to it.
Separate execute_child from drain_output so descriptor ownership is easy to trace.
Save errno before cleanup, retry EINTR where appropriate, and attempt to reap even after a read error.
Finite synthetic output and status generator
Keep these file names and folders when building. Long lines scroll within the code panel.
/* Leon | Original teaching project. */
#include <signal.h>
#include <stdio.h>
#include <string.h>
/* Synthetic, finite output only: no files, descendants, input, or sleeps. */
int main(int argc, char **argv)
{
if (argc < 2) {
return 2;
}
if (strcmp(argv[1], "args") == 0) {
for (int i = 2; i < argc; ++i) {
printf("[%s]", argv[i]);
}
} else if (strcmp(argv[1], "burst") == 0) {
char block[4096];
memset(block, 'A', sizeof(block));
for (int i = 0; i < 256; ++i) {
if (fwrite(block, 1, sizeof(block), stdout) != sizeof(block)) {
return 3;
}
}
} else if (strcmp(argv[1], "fragment") == 0) {
fputs("tail", stdout);
} else if (strcmp(argv[1], "fail") == 0) {
puts("check failed");
return 7;
} else if (strcmp(argv[1], "stderr") == 0) {
fputs("diagnostic\n", stderr);
puts("ok");
} else if (strcmp(argv[1], "bytes") == 0) {
const unsigned char bytes[] = {0, 255, '"', '\\', '\t', '\r', '\n'};
if (fwrite(bytes, 1, sizeof(bytes), stdout) != sizeof(bytes)) {
return 3;
}
} else if (strcmp(argv[1], "signal") == 0) {
(void)raise(SIGTERM);
return 3;
} else if (strcmp(argv[1], "empty") != 0) {
return 2;
}
return fflush(stdout) == 0 ? 0 : 3;
}
args surrounds each argument with brackets so spaces remain visible.
burst writes 256 fixed blocks; empty and fragment probe EOF without line assumptions.
fail, stderr, bytes, and signal isolate distinct observable behaviors.
Fourteen real-process regression tests
Keep these file names and folders when building. Long lines scroll within the code panel.
#!/usr/bin/env python3
# Leon | Original teaching project.
"""Build a temporary copy and exercise only bounded, local fixture programs."""
import os
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
import unittest
SOURCE = Path(__file__).resolve().parent
class RunnerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix="guoliang-process-test-")
cls.addClassCleanup(cls.temp.cleanup)
cls.work = Path(cls.temp.name)
for name in ("main.c", "process.c", "process.h", "Makefile"):
shutil.copy2(SOURCE / name, cls.work / name)
shutil.copytree(SOURCE / "fixtures", cls.work / "fixtures")
built = subprocess.run(["make", "all"], cwd=cls.work, text=True,
capture_output=True, timeout=30, check=False)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
def run_runner(self, *args):
# A new group lets a timeout clean up both runner and its own child.
child = subprocess.Popen(["./process_runner", *args], cwd=self.work,
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, start_new_session=True)
try:
out, err = child.communicate(timeout=8)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.communicate()
self.fail("bounded fixture timed out; possible pipe/wait deadlock")
return child.returncode, out.decode("ascii"), err.decode("ascii")
def test_echo_and_literal_space_argument(self):
# Catches splitting one argument or losing the trailing newline.
self.assertEqual(self.run_runner("32", "--", "/bin/echo", "hello team"),
(0, 'stdout="hello team\\n"\ncaptured_bytes=11\n'
'truncated=no\nchild_exit=0\n', ""))
def test_metacharacters_are_arguments(self):
# No shell may expand the dollar sign, wildcard, or semicolon.
self.assertEqual(self.run_runner("64", "--", "./fixture_child", "args",
"two words", "$HOME;*"),
(0, 'stdout="[two words][$HOME;*]"\ncaptured_bytes=20\n'
'truncated=no\nchild_exit=0\n', ""))
def test_large_output_is_drained_after_cap(self):
self.assertEqual(self.run_runner("8", "--", "./fixture_child", "burst"),
(0, 'stdout="AAAAAAAA"\ncaptured_bytes=8\n'
'truncated=yes\nchild_exit=0\n', ""))
def test_zero_cap_still_drains(self):
self.assertEqual(self.run_runner("0", "--", "./fixture_child", "burst"),
(0, 'stdout=""\ncaptured_bytes=0\n'
'truncated=yes\nchild_exit=0\n', ""))
def test_empty_output_is_not_truncation(self):
self.assertEqual(self.run_runner("0", "--", "./fixture_child", "empty"),
(0, 'stdout=""\ncaptured_bytes=0\n'
'truncated=no\nchild_exit=0\n', ""))
def test_exact_cap_and_final_fragment(self):
self.assertEqual(self.run_runner("4", "--", "./fixture_child", "fragment"),
(0, 'stdout="tail"\ncaptured_bytes=4\n'
'truncated=no\nchild_exit=0\n', ""))
def test_one_byte_over_cap(self):
self.assertEqual(self.run_runner("3", "--", "./fixture_child", "fragment"),
(0, 'stdout="tai"\ncaptured_bytes=3\n'
'truncated=yes\nchild_exit=0\n', ""))
def test_nonzero_child_status_is_returned(self):
self.assertEqual(self.run_runner("32", "--", "./fixture_child", "fail"),
(7, 'stdout="check failed\\n"\ncaptured_bytes=13\n'
'truncated=no\nchild_exit=7\n', ""))
def test_missing_program_is_reaped(self):
self.assertEqual(self.run_runner("16", "--", "./absent-program"),
(127, 'stdout=""\ncaptured_bytes=0\n'
'truncated=no\nchild_exit=127\n',
"process_runner: execvp failed\n"))
def test_stderr_is_inherited_not_captured(self):
self.assertEqual(self.run_runner("16", "--", "./fixture_child", "stderr"),
(0, 'stdout="ok\\n"\ncaptured_bytes=3\n'
'truncated=no\nchild_exit=0\n', "diagnostic\n"))
def test_byte_escaping_is_lossless_for_the_prefix(self):
self.assertEqual(self.run_runner("16", "--", "./fixture_child", "bytes"),
(0, 'stdout="\\x00\\xFF\\\"\\\\\\t\\r\\n"\ncaptured_bytes=7\n'
'truncated=no\nchild_exit=0\n', ""))
def test_signal_status_is_reported(self):
code, out, err = self.run_runner("16", "--", "./fixture_child", "signal")
self.assertEqual(code, 128 + signal.SIGTERM)
self.assertEqual(out, 'stdout=""\ncaptured_bytes=0\ntruncated=no\n'
f'child_signal={signal.SIGTERM}\n')
self.assertEqual(err, "")
def test_maximum_cap(self):
code, out, err = self.run_runner("65536", "--", "./fixture_child", "burst")
self.assertEqual(code, 0)
self.assertEqual(out, 'stdout="' + "A" * 65536 + '"\ncaptured_bytes=65536\n'
'truncated=yes\nchild_exit=0\n')
self.assertEqual(err, "")
def test_bad_cli_never_starts_a_child(self):
cases = [(), ("8", "--"), ("8", "--", ""),
("-1", "--", "./fixture_child", "empty"),
("65537", "--", "./fixture_child", "empty"),
("8x", "--", "./fixture_child", "empty"),
(" 8", "--", "./fixture_child", "empty"),
("9999999999999999999999999", "--", "./fixture_child", "empty"),
("8", "wrong", "./fixture_child", "empty")]
for args in cases:
with self.subTest(args=args):
code, out, err = self.run_runner(*args)
self.assertEqual(code, 2)
self.assertEqual(out, "")
self.assertEqual(err, "usage: process_runner CAP -- PROGRAM [ARG ...]\n"
"CAP must be decimal digits from 0 to 65536.\n")
if __name__ == "__main__":
unittest.main(verbosity=2)
Copy only project inputs into TemporaryDirectory and compile there.
Popen uses an argument list, DEVNULL stdin, separate output pipes, a new session, and an 8-second watchdog.
Assertions compare real stdout, stderr, and return codes with hand-derived expectations; timeout kills only the test group.
Strict, repeatable all/test/clean entry points
Keep these file names and folders when building. Long lines scroll within the code panel.
# Leon | Original teaching project.
CC = clang
CFLAGS = -std=c11 -Wall -Wextra -Werror -pedantic -pthread
CPPFLAGS = -D_POSIX_C_SOURCE=200809L
.PHONY: all test clean
all: process_runner fixture_child
process_runner: main.c process.c process.h
$(CC) $(CPPFLAGS) $(CFLAGS) main.c process.c -o $@
fixture_child: fixtures/child.c
$(CC) $(CPPFLAGS) $(CFLAGS) fixtures/child.c -o $@
test:
python3 test.py
clean:
rm -f process_runner fixture_child
all compiles the runner and independent fixture helper.
C11, POSIX feature declarations, warnings, and Werror are explicit.
test invokes Python; clean removes only the two generated executables.
Create a second pipe with close-on-exec on the child write end. Successful exec closes it automatically; a failed exec writes a small errno record before _exit. Preserve ownership and complete both error and stdout reads.
Distinguish a real program returning 127 from failure to execute a missing path, with both children reaped and no descriptor leaks.
Specify process-group ownership, monotonic deadlines, poll-based draining, a termination grace period, final forced cleanup, and a policy for descendants and caller signals before coding. Use only finite owned fixtures while developing.
A controlled child exceeding its budget yields a timeout outcome within a bounded interval; all owned children are reaped, pipes close, and retained output remains correct. Tests must not assert exact scheduling times.