接受 CAP -- PROGRAM [ARG ...],CAP 范围是 0 到 65536。
内存预算必须明确,并在工作开始前完成检查。
检查方法:格式错误、负数、超大、缺少或混入字母的容量返回 2,不生成标准输出报告。
运行一个本地程序,保存有容量上限的标准输出前缀,继续读完其余字节,并准确解释子进程怎样结束。
小梅在分享团队的传感器报告前,会先运行检查程序。有的检查只输出一句成功提示,有的会打印很多细节后返回错误。复制终端文字容易漏掉退出状态,保存所有字节又浪费内存。她实现一个小记录器,只保留前 C 个字节,说明是否还有更多输出,并记录子进程结果。先从带空格参数的 echo 开始,再解决管道写满、程序不存在、最后一行没有换行的问题。完成后,她能说明每个描述符和保存字节的来历,也能说明工具尚未覆盖的情况。这是Leon 原创学习项目,与现成课程作业无关。
内存预算必须明确,并在工作开始前完成检查。
检查方法:格式错误、负数、超大、缺少或混入字母的容量返回 2,不生成标准输出报告。
拼接字符串会破坏参数边界,还可能引入 shell 解释。
检查方法:测试程序将 two words 作为一个参数收到,并原样收到 $HOME;*。
达到上限就停止读取,可能让子进程卡在写满的管道上。
检查方法:1 MiB 输出在容量为 8 或 0 时都能完成,并报告截断。
单一管道让所有权清楚,也不把两个流说成具有可靠的统一顺序。
检查方法:stderr 测试生成包含 ok 的报告,同时单独输出诊断。
EOF 要求所有写端都关闭;进程结束仍需要 waitpid 处理。
检查方法:分别验证正常退出 0、退出 7、缺少程序 127 和 SIGTERM。
保存的前缀可能包含 NUL、控制字节或多字节字符的一部分。
检查方法:验证 NUL、0xFF、引号、反斜杠、制表符、回车和换行都保留原字节含义。
检查容量、分配字节缓冲区、借用 argv、调用进程模块、转义保存的字节,并返回子进程结果。
创建管道和子进程,重定向子进程 stdout,父进程读取读端,并回收这个子进程。公开结果包含字节数、截断标记和原始等待状态。
产生受控输出和结果,不使用文件、后代进程、输入、睡眠或网络。burst 模式恰好写出 256 个 4096 字节块。
在临时目录编译副本,把实际输出与返回码同手工预期比较。每次运行都有 8 秒看门狗和独立进程组。
检查参数 → 分配 → pipe → fork。子进程:关读端 → dup2(写端,1) → 关原写端 → execvp。父进程:关写端 → 读到 EOF → 关读端 → waitpid → 转义并报告 → free。父子进程并发运行,没有固定调度顺序。read 读取已经写入的字节,所有写端关闭且队列读空后才有 EOF,最终报告在成功等待之后生成。
| 资源 | 所有者 | 释放时机 |
|---|---|---|
| 管道读端 | fork 后由父进程保留;子进程最初也有副本。 | 子进程在 exec 前关闭副本;父进程读取结束或失败后关闭。 |
| 原始管道写端 | fork 后双方短暂持有。 | 父进程立即关闭;子进程 dup2 后关闭,stdout 仍指向同一管道。 |
| 子进程标准输出,描述符 1 | 执行后的程序,以及继承它的后代进程。 | 程序关闭或进程结束时释放;所有写端都关闭后才可能有 EOF。 |
| 输出缓冲区与参数数组 | main 拥有分配内存;process_run 借用该内存和 argv 字符串。 | main 在错误时或报告后释放缓冲区;本模块不释放借用的 argv。 |
| 直接子进程及结束状态 | 父进程负责回收 fork 返回的 PID。 | waitpid 遇到 EINTR 重试并取得结束状态;读取失败也尝试终止并回收。 |
检查命令行,并给缓冲区明确的所有者。
小梅先决定字节预算,再运行检查程序。有限范围的十进制解析器在乘法前防止溢出;argv 已经提供所需的参数边界。
用 make 编译完整项目。先读 process.h,再看 main。
跟踪 65536 的解析过程,再说明为什么 65537 和 8x 被拒绝。
跟踪 malloc、process_run(&argv[3], ...) 和 free。进程模块不能释放借来的内存。
这是完整工程中的教学摘录。请构建下面的完整文件后运行。
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;
}
这是 main.c 的节选,不是独立程序。digit 范围是 0–9。先检查 value <= (MAX-digit)/10,才能保证 value*10+digit 不超过上限。空字符串和任何非数字都会失败。
它指向程序名、已有参数和末尾 NULL。拼接后就无法区分哪些空格属于参数内部。
把子进程 stdout 改为管道,同时保留 stdin 和 stderr。
fork 会复制描述符,所以双方最初都持有两端。exec 替换程序之前,子进程描述符 1 必须已经指向管道。
画出 fork 后的两张描述符表,每张都有读端和写端。
子进程先关读端,把写端 dup2 到描述符 1,再关原写端。
使用未改写的参数数组调用 execvp。失败时向 stderr 写固定诊断,然后调用 _exit(127)。
这是完整工程中的教学摘录。请构建下面的完整文件后运行。
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);
}
这是 process.c 的节选。调用者检查 0–2 已打开,保证新管道描述符大于 2。dup2 让 stdout 成为写端的另一个引用;关闭原描述符不会关闭 stdout。exec 成功就不会返回。_exit 跳过复制来的 stdio 缓冲区和 atexit 回调。
不会。dup2 成功后,描述符 1 仍指向管道。这个引用在 exec 后保留,到程序主动关闭或退出时才关闭。
用有限内存处理空输出、恰好达到上限、超过上限和没有换行的输出。
内核管道容量也有限。如果小梅缓冲区满了就停止读取并开始等待,大量输出的子进程可能正在等管道腾出空间,双方就都无法前进。
每次 read 返回正数后,计算 space=capacity-captured,再计算 keep=min(count,space)。
只复制 keep 个字节。只有确实有字节放不下时才标记截断;刚好达到上限不算截断。
继续读,直到返回 0 表示 EOF。EINTR 要重试;没有换行的末尾片段也是数据。
这是完整工程中的教学摘录。请构建下面的完整文件后运行。
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. */
}
}
这是 process.c 的节选。不变量是 0<=captured<=capacity。一次 read 可能只读到少量字节,一次 write 不一定对应一次 read。N 字节输出需要 O(N) 读取工作,容量 C 对应 O(C+4096) 内存。没有累加丢弃总量的计数器,所以不会在该计数上溢出。
容量 4 保存 tail,truncated=no;容量 3 保存 tai,truncated=yes;容量 0 不保存字节,truncated=yes。三者仍会读完相同的四个字节。
完成描述符清理,并区分正常退出和信号终止。
EOF 不能证明子进程结束:它可能关闭 stdout 后继续运行。反过来,后代也可能在直接子进程结束后继续持有写端。小梅还需要 waitpid 检查另一种完成条件。
读取前关闭父进程写端,否则父进程自己就会阻止 EOF。
读完后关读端,再等待指定子进程 PID。只有 errno 为 EINTR 时重试 waitpid。
使用等待状态宏,显示转义字节前缀与状态,然后释放缓冲区。读取失败时终止直接子进程,并仍尝试回收。
这是完整工程中的教学摘录。请构建下面的完整文件后运行。
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;
}
这是 main.c 的节选。先判断 WIFEXITED 才用 WEXITSTATUS;先判断 WIFSIGNALED 才用 WTERMSIG。原始等待整数本身不是退出码。普通退出返回子进程代码;信号单独显示,在支持的平台上映射为 128+信号编号。
不能。所选程序自己也可能返回 127。本项目在 exec 失败时另写固定 stderr 信息,但没有独立 exec 错误协议。可通过额外的 close-on-exec 错误管道扩展。
验证字节、截断、stderr 策略和状态,不假定调度先后。
运行真实子进程比检查源码文字更有说服力。合成测试程序的行为有限且已知,小梅能够独立算出预期,并为整个运行加看门狗。
运行 make test。它在临时目录严格编译,只调用本地测试程序、/bin/echo 和故意不存在的路径。
比较精确输出、字节数、子进程状态和独立 stderr。无论 read 如何分块,burst 都是 256*4096=1048576 个字节。
测试零容量、恰好与超过上限、字面特殊字符、非零退出、信号退出、二进制转义和缺失程序。超时只清理独立的测试进程组。
这是完整工程中的教学摘录。请构建下面的完整文件后运行。
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;
}
这是 fixtures/child.c 的节选,它是测试使用的独立有限程序。辅助程序既不 fork,也不读取 stdin。burst 输出有限,fail 返回 7,fragment 不带换行。这些例子用于测试本项目,不是现成作业答案。
它说明本次运行在只保留容量上限的同时,读完了有限的大输出并回收子进程。它不能证明所有调度、强制 EINTR 或错误路径、后代清理和生产级超时策略。
使用 macOS 或 Linux 上支持 C11 的 Clang、make 和 Python 3。Windows 可使用 WSL 等 Linux 环境。这里的 Makefile 默认使用 Clang。在下载目录打开终端,解压并进入工程文件夹。make 构建可执行文件;make test 检查程序行为。
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
退出状态:0.
shell 引号在记录器启动前构造一个 hello team 参数。子进程写出 11 字节,包含换行。记录器在 EOF 和 waitpid 后返回 0。
./process_runner 8 -- ./fixture_child burst
stdout="AAAAAAAA" captured_bytes=8 truncated=yes child_exit=0
退出状态:0.
辅助程序写出 1,048,576 个 A。只保存八个,但读完其余全部输出,让子进程能够结束。截断不会让子进程失败:记录器返回 0。
./process_runner 32 -- ./fixture_child fail
stdout="check failed\n" captured_bytes=13 truncated=no child_exit=7
退出状态:7.
13 字节信息完整保存,但子进程返回 7。记录器也返回 7。捕获成功与子进程成功是不同结论。
./process_runner 16 -- ./absent-program
stdout="" captured_bytes=0 truncated=no child_exit=127
退出状态:127.
子进程向继承的 stderr 写出 process_runner: execvp failed 和换行(不在此 stdout 区块中),再调用 _exit(127)。父进程读到 EOF、回收它,并返回 127。真实程序也能返回 127,因此只看状态存在歧义。
./process_runner 4 -- ./fixture_child fragment
stdout="tail" captured_bytes=4 truncated=no child_exit=0
退出状态:0.
tail 恰好四字节。没有丢弃字节,所以容量虽然已满,仍是 truncated=no。字节流结束不要求换行。记录器返回 0。
./process_runner 64 -- ./fixture_child args 'two words' '$HOME;*'
stdout="[two words][$HOME;*]" captured_bytes=20 truncated=no child_exit=0
退出状态:0.
测试程序给每个参数加方括号。20 字节结果说明 two words 保持完整,$HOME;* 保持字面含义。记录器返回 0,从未构造命令字符串。
父进程先等待再读取,或达到容量就停止读取。
用 make test 在容量 8 下运行有限 burst;检查每次正数读取后是否继续 read。
即使 keep 变成 0,也要读到 EOF,再调用 waitpid。
父进程保留了写端,或其他进程继承了写端。
画出双方所有写端描述符;检查父进程是否在 fork 后立即 close。
关闭父进程不用的写端。后代保留写端的问题需要未来的进程树策略。
代码把容量满了误当成已经丢弃数据。
比较 fragment 在容量 4 和 3 下的结果;只有后者丢失一个字节。
只在真实正数读取中 keep<count 时设置截断。
代码把原始等待状态当成退出码输出。
运行 fail 测试,检查 WEXITSTATUS 前是否判断 WIFEXITED。
用等待宏解码,并在独立分支检查信号终止。
对原始捕获字节使用了 strlen 等字符串函数。
运行 bytes 测试:七个字节都必须以字符或转义形式可见。
循环到 result.captured,使用 unsigned char,并逐字节转义。
外层 shell 调用没有正确引用,或实现中引入了命令字符串。
使用 args 测试,并在终端用单引号包围字面参数。
记录器保留收到的 argv;只在构造初始参数时使用 shell 引号。
echo 输出 hello team 和换行:11 字节,退出 0。
发现意外拆分参数或丢失换行。
args 测试输出 [two words][$HOME;*]:20 字节。
发现命令拼接或展开。
恰好保留容量个字节,报告 truncated=yes,并在看门狗之前退出 0。
验证容量用完后仍有进展,并覆盖容量两端。
保存零字节,truncated=no,退出 0。
预算为零本身不能证明丢失数据。
分别得到 tail/no 和 tai/yes,不要求换行。
区分刚好达到容量和溢出,也区分字节流与文本行。
保存 check failed 和换行,显示 child_exit=7,返回 7。
记录器运行成功不能抹掉子进程失败状态。
空捕获、固定 execvp 标准错误诊断、child_exit=127、返回 127。
覆盖子进程 _exit 路径和父进程 EOF/wait 生命周期。
stderr 保持独立;bytes 测试用可逆转义显示全部七个字节。
防止流混合、NUL 截断和原始终端控制字节输出。
用平台信号编号显示 child_signal,并返回 128 加该编号。
不依赖时序地覆盖另一种等待状态分支。
返回 2,在 stderr 显示用法,没有 stdout 报告。
检查在分配或 fork 前拒绝输入,并防止数字溢出。
保存字节数=min(N,C),截断=(N>C)。读取仍需 O(N) 工作,内存为 O(C+4096)。没有超时或进程树控制。子进程等输入、后代保留 stdout、stderr 接收端阻塞或子进程一直不结束,都可能让运行停住。测试看门狗属于 test.py,不属于记录器。
stdout 被捕获并在结束后显示。stdin 和 stderr 保持继承,stderr 可能先于稍后的 stdout 报告出现,但这不代表存在统一顺序。字节用 ASCII 转义显示,不做 Unicode 解码;容量可能切断 UTF-8 序列,但保留的字节仍可见。这不是原始二进制复制工具。
argv 参数边界保持不变。无路径程序名使用继承的 PATH;明确路径更容易确定目标。POSIX execvp 对格式无法识别的可执行文本文件可能调用解释器。回退解释的是该文件,不是新拼接的参数字符串。程序继承权限、目录、环境和其他未设置 close-on-exec 的描述符;这不是沙箱。
普通子进程退出码原样返回。信号单独显示并映射为 128+N。命令行错误返回 2,已处理的记录器错误返回 125,子进程配置错误返回 126,exec 失败返回 127。子进程本身也能返回这些代码。没有 exec 错误管道,因此单看 127 无法识别 exec 失败。固定子进程诊断保留在独立 stderr 上。
模块假设单线程、描述符 0–2 已打开、阻塞 I/O、默认 SIGCHLD 处理且没有其他回收者。read/waitpid/dup2/子进程诊断 write 遇到 EINTR 会重试。读取失败只终止直接子进程并尝试回收。close 尽力执行而不重试;省略跨平台 close 中断恢复。没有应用信号处理器、转发或用户取消策略。中断记录器可能留下子进程,报告管道断开可能触发默认 SIGPIPE 终止。
下面是下载包中的完整文件。编号表示建议阅读顺序。点击文件清单可直接跳转到代码。
英文构建与工程指南
构建时请保留文件名和目录结构。长行在代码框内横向滚动。
# 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)
先读四个实际命令例子及退出含义。
按五个里程碑与描述符所有权表阅读。
运行有限测试程序以外的程序前,先读适用范围。
完整中文配套指南
构建时请保留文件名和目录结构。长行在代码框内横向滚动。
# 项目 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)
使用相同的编译、运行、测试和清理命令。
把 EOF 与 wait 作为两个不同条件跟踪。
查看信号、exec 回退和资源继承的中文解释。
公开数据与所有权约定
构建时请保留文件名和目录结构。长行在代码框内横向滚动。
/* 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
查看容量上限与 process_result 的三个字段。
注意缓冲区与 argv 只是借用,没有转移所有权。
返回 0 表示成功回收子进程,即使子进程本身返回非零。
命令行检查、字节显示与退出码映射
构建时请保留文件名和目录结构。长行在代码框内横向滚动。
/* 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 在分配前拒绝格式错误或越界输入。
print_bytes 使用字节数而非 strlen,因此保留内部 NUL。
report 解码等待状态;main 在已处理路径释放内存,并检查最终 stdout 刷新。
管道配置、fork/exec、连续读取与等待
构建时请保留文件名和目录结构。长行在代码框内横向滚动。
/* 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;
}
先检查标准描述符,再在 fork 前创建管道,让双方都能引用。
把 execute_child 与 drain_output 分开,便于跟踪描述符所有权。
清理前保存 errno,在适当处重试 EINTR,并在读取失败后仍尝试回收。
有限的合成输出与状态生成器
构建时请保留文件名和目录结构。长行在代码框内横向滚动。
/* 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 给每个参数加方括号,让空格边界可见。
burst 写 256 个固定块;empty 与 fragment 在不依赖行的情况下检查 EOF。
fail、stderr、bytes 和 signal 分别产生不同可观察行为。
十四个真实进程回归测试
构建时请保留文件名和目录结构。长行在代码框内横向滚动。
#!/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)
只把项目输入复制到 TemporaryDirectory,再在那里编译。
Popen 使用参数列表、DEVNULL 输入、分离的输出管道、新会话和 8 秒看门狗。
断言把实际 stdout、stderr 和返回码与手工预期比较;超时只终止测试进程组。
严格且可重复的 all/test/clean 入口
构建时请保留文件名和目录结构。长行在代码框内横向滚动。
# 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 编译记录器和独立测试辅助程序。
明确指定 C11、POSIX 功能声明、警告和 Werror。
test 调用 Python;clean 只删除两个生成的可执行文件。
增加第二条管道,给子进程写端设置 close-on-exec。exec 成功自动关闭它,exec 失败则先写入简短 errno 记录,再 _exit。保持清晰所有权,并完整处理错误通道和 stdout。
区分真实程序返回 127 与无法执行缺失路径;两者都回收子进程且不泄漏描述符。
先明确进程组所有权、单调时钟截止时间、基于 poll 的读取、终止宽限期、最终强制清理,以及后代和调用者信号策略,再编码。开发时只用有限且自己拥有的测试程序。
受控子进程超过预算时,在有界时间内产生超时结果;所有自己管理的子进程被回收、管道关闭、保存输出正确。测试不能断言精确调度时间。