LWLeon
LEON / COMP2017 / PROJECT 02

有容量上限的进程记录器

运行一个本地程序,保存有容量上限的标准输出前缀,继续读完其余字节,并准确解释子进程怎样结束。

C 语言进阶:一个父进程、一个子进程、一条管道,通过五个连续里程碑完成。 · C11 / POSIX · EN / ZH

LEON / BUILD ROUTE

每次完成一个可运行的小部分

  1. 01 →

    1. 定义一次运行的约定

    检查命令行,并给缓冲区明确的所有者。

  2. 02 →

    2. 给子进程连接输出通道

    把子进程 stdout 改为管道,同时保留 stdin 和 stderr。

  3. 03 →

    3. 保存前缀并持续读取

    用有限内存处理空输出、恰好达到上限、超过上限和没有换行的输出。

  4. 04 →

    4. 回收并解释结束结果

    完成描述符清理,并区分正常退出和信号终止。

  5. 05 →

    5. 用受控子进程检查记录器

    验证字节、截断、stderr 策略和状态,不假定调度先后。

阅读字号
构建并运行这个工程 ↓

从一个完整故事开始

小梅在分享团队的传感器报告前,会先运行检查程序。有的检查只输出一句成功提示,有的会打印很多细节后返回错误。复制终端文字容易漏掉退出状态,保存所有字节又浪费内存。她实现一个小记录器,只保留前 C 个字节,说明是否还有更多输出,并记录子进程结果。先从带空格参数的 echo 开始,再解决管道写满、程序不存在、最后一行没有换行的问题。完成后,她能说明每个描述符和保存字节的来历,也能说明工具尚未覆盖的情况。这是Leon 原创学习项目,与现成课程作业无关。

开始前先读

完成后你能够

  • 使用 fork 和 execvp 执行程序,并保留原有参数边界。
  • 限制保存输出的内存,同时持续读取,让子进程能够继续执行。
  • 区分 EOF、成功等待、正常退出、信号终止和记录器自身错误。
  • 测试可观察的字节与状态码,不依赖进程调度先后。

把需求写成可检查的行为

接受 CAP -- PROGRAM [ARG ...],CAP 范围是 0 到 65536。

内存预算必须明确,并在工作开始前完成检查。

检查方法:格式错误、负数、超大、缺少或混入字母的容量返回 2,不生成标准输出报告。

把已有 argv 的一部分直接传给 execvp。

拼接字符串会破坏参数边界,还可能引入 shell 解释。

检查方法:测试程序将 two words 作为一个参数收到,并原样收到 $HOME;*。

最多保存 CAP 个字节,但一直读取到 EOF。

达到上限就停止读取,可能让子进程卡在写满的管道上。

检查方法:1 MiB 输出在容量为 8 或 0 时都能完成,并报告截断。

只捕获 stdout;继承 stdin 与 stderr。

单一管道让所有权清楚,也不把两个流说成具有可靠的统一顺序。

检查方法:stderr 测试生成包含 ok 的报告,同时单独输出诊断。

关闭不用的描述符,回收直接子进程,并解码其等待状态。

EOF 要求所有写端都关闭;进程结束仍需要 waitpid 处理。

检查方法:分别验证正常退出 0、退出 7、缺少程序 127 和 SIGTERM。

用确定的 ASCII 转义显示字节。

保存的前缀可能包含 NUL、控制字节或多字节字符的一部分。

检查方法:验证 NUL、0xFF、引号、反斜杠、制表符、回车和换行都保留原字节含义。

追踪数据和它的所有者

  1. 命令行与报告:main.c

    检查容量、分配字节缓冲区、借用 argv、调用进程模块、转义保存的字节,并返回子进程结果。

  2. 进程生命周期:process.c / process.h

    创建管道和子进程,重定向子进程 stdout,父进程读取读端,并回收这个子进程。公开结果包含字节数、截断标记和原始等待状态。

  3. 有限测试程序:fixtures/child.c

    产生受控输出和结果,不使用文件、后代进程、输入、睡眠或网络。burst 模式恰好写出 256 个 4096 字节块。

  4. 独立检查:test.py

    在临时目录编译副本,把实际输出与返回码同手工预期比较。每次运行都有 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 重试并取得结束状态;读取失败也尝试终止并回收。

01 / 1. 定义一次运行的约定

检查命令行,并给缓冲区明确的所有者。

小梅先决定字节预算,再运行检查程序。有限范围的十进制解析器在乘法前防止溢出;argv 已经提供所需的参数边界。

  1. 用 make 编译完整项目。先读 process.h,再看 main。

  2. 跟踪 65536 的解析过程,再说明为什么 65537 和 8x 被拒绝。

  3. 跟踪 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 不超过上限。空字符串和任何非数字都会失败。

为什么传 &argv[3],而不是把后面的字符串拼起来?

它指向程序名、已有参数和末尾 NULL。拼接后就无法区分哪些空格属于参数内部。

02 / 2. 给子进程连接输出通道

把子进程 stdout 改为管道,同时保留 stdin 和 stderr。

fork 会复制描述符,所以双方最初都持有两端。exec 替换程序之前,子进程描述符 1 必须已经指向管道。

  1. 画出 fork 后的两张描述符表,每张都有读端和写端。

  2. 子进程先关读端,把写端 dup2 到描述符 1,再关原写端。

  3. 使用未改写的参数数组调用 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 后保留,到程序主动关闭或退出时才关闭。

03 / 3. 保存前缀并持续读取

用有限内存处理空输出、恰好达到上限、超过上限和没有换行的输出。

内核管道容量也有限。如果小梅缓冲区满了就停止读取并开始等待,大量输出的子进程可能正在等管道腾出空间,双方就都无法前进。

  1. 每次 read 返回正数后,计算 space=capacity-captured,再计算 keep=min(count,space)。

  2. 只复制 keep 个字节。只有确实有字节放不下时才标记截断;刚好达到上限不算截断。

  3. 继续读,直到返回 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) 内存。没有累加丢弃总量的计数器,所以不会在该计数上溢出。

子进程输出 tail,恰好四个字节。容量 4、3、0 有何区别?

容量 4 保存 tail,truncated=no;容量 3 保存 tai,truncated=yes;容量 0 不保存字节,truncated=yes。三者仍会读完相同的四个字节。

04 / 4. 回收并解释结束结果

完成描述符清理,并区分正常退出和信号终止。

EOF 不能证明子进程结束:它可能关闭 stdout 后继续运行。反过来,后代也可能在直接子进程结束后继续持有写端。小梅还需要 waitpid 检查另一种完成条件。

  1. 读取前关闭父进程写端,否则父进程自己就会阻止 EOF。

  2. 读完后关读端,再等待指定子进程 PID。只有 errno 为 EINTR 时重试 waitpid。

  3. 使用等待状态宏,显示转义字节前缀与状态,然后释放缓冲区。读取失败时终止直接子进程,并仍尝试回收。

这是完整工程中的教学摘录。请构建下面的完整文件后运行。

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+信号编号。

只看 child_exit=127,能证明 execvp 失败吗?

不能。所选程序自己也可能返回 127。本项目在 exec 失败时另写固定 stderr 信息,但没有独立 exec 错误协议。可通过额外的 close-on-exec 错误管道扩展。

05 / 5. 用受控子进程检查记录器

验证字节、截断、stderr 策略和状态,不假定调度先后。

运行真实子进程比检查源码文字更有说服力。合成测试程序的行为有限且已知,小梅能够独立算出预期,并为整个运行加看门狗。

  1. 运行 make test。它在临时目录严格编译,只调用本地测试程序、/bin/echo 和故意不存在的路径。

  2. 比较精确输出、字节数、子进程状态和独立 stderr。无论 read 如何分块,burst 都是 256*4096=1048576 个字节。

  3. 测试零容量、恰好与超过上限、字面特殊字符、非零退出、信号退出、二进制转义和缺失程序。超时只清理独立的测试进程组。

这是完整工程中的教学摘录。请构建下面的完整文件后运行。

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 不带换行。这些例子用于测试本项目,不是现成作业答案。

burst 测试通过说明了什么?还有什么无法证明?

它说明本次运行在只保留容量上限的同时,读完了有限的大输出并回收子进程。它不能证明所有调度、强制 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。

原样传递空格和 shell 特殊字符

./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。

子进程已退出,但一直没有 EOF。

提出假设

父进程保留了写端,或其他进程继承了写端。

检查相关状态

画出双方所有写端描述符;检查父进程是否在 fork 后立即 close。

修复并验证

关闭父进程不用的写端。后代保留写端的问题需要未来的进程树策略。

四字节 tail 在容量 4 时被错误标成截断。

提出假设

代码把容量满了误当成已经丢弃数据。

检查相关状态

比较 fragment 在容量 4 和 3 下的结果;只有后者丢失一个字节。

修复并验证

只在真实正数读取中 keep<count 时设置截断。

报告退出码看起来是 1792,而不是 7。

提出假设

代码把原始等待状态当成退出码输出。

检查相关状态

运行 fail 测试,检查 WEXITSTATUS 前是否判断 WIFEXITED。

修复并验证

用等待宏解码,并在独立分支检查信号终止。

NUL 字节之后的输出消失。

提出假设

对原始捕获字节使用了 strlen 等字符串函数。

检查相关状态

运行 bytes 测试:七个字节都必须以字符或转义形式可见。

修复并验证

循环到 result.captured,使用 unsigned char,并逐字节转义。

含空格的参数变成两个,或 $HOME 被展开。

提出假设

外层 shell 调用没有正确引用,或实现中引入了命令字符串。

检查相关状态

使用 args 测试,并在终端用单引号包围字面参数。

修复并验证

记录器保留收到的 argv;只在构造初始参数时使用 shell 引号。

验证正常行为,也验证失败

包含空格的一个参数

echo 输出 hello team 和换行:11 字节,退出 0。

发现意外拆分参数或丢失换行。

字面参数 $HOME;*

args 测试输出 [two words][$HOME;*]:20 字节。

发现命令拼接或展开。

1 MiB 输出,容量分别为 8、0、65536

恰好保留容量个字节,报告 truncated=yes,并在看门狗之前退出 0。

验证容量用完后仍有进展,并覆盖容量两端。

零容量下的空输出

保存零字节,truncated=no,退出 0。

预算为零本身不能证明丢失数据。

末尾片段 tail,容量 4 与 3

分别得到 tail/no 和 tai/yes,不要求换行。

区分刚好达到容量和溢出,也区分字节流与文本行。

检查程序返回 7

保存 check failed 和换行,显示 child_exit=7,返回 7。

记录器运行成功不能抹掉子进程失败状态。

本地可执行文件不存在

空捕获、固定 execvp 标准错误诊断、child_exit=127、返回 127。

覆盖子进程 _exit 路径和父进程 EOF/wait 生命周期。

子进程标准错误和二进制标准输出

stderr 保持独立;bytes 测试用可逆转义显示全部七个字节。

防止流混合、NUL 截断和原始终端控制字节输出。

子进程用 SIGTERM 结束自己

用平台信号编号显示 child_signal,并返回 128 加该编号。

不依赖时序地覆盖另一种等待状态分支。

错误命令行、负数或过大容量、缺少程序

返回 2,在 stderr 显示用法,没有 stdout 报告。

检查在分配或 fork 前拒绝输入,并防止数字溢出。

Leon 的工程取舍

字节预算不是时间预算

保存字节数=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 上。

有限 POSIX 范围与明确清理

模块假设单线程、描述符 0–2 已打开、阻塞 I/O、默认 SIGCHLD 处理且没有其他回收者。read/waitpid/dup2/子进程诊断 write 遇到 EINTR 会重试。读取失败只终止直接子进程并尝试回收。close 尽力执行而不重试;省略跨平台 close 中断恢复。没有应用信号处理器、转发或用户取消策略。中断记录器可能留下子进程,报告管道断开可能触发默认 SIGPIPE 终止。

按顺序阅读完整文件

下面是下载包中的完整文件。编号表示建议阅读顺序。点击文件清单可直接跳转到代码。

01 / README.md

英文构建与工程指南

下载文件 ↓

构建时请保留文件名和目录结构。长行在代码框内横向滚动。

# 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)
  1. 先读四个实际命令例子及退出含义。

  2. 按五个里程碑与描述符所有权表阅读。

  3. 运行有限测试程序以外的程序前,先读适用范围。

02 / README.zh.md

完整中文配套指南

下载文件 ↓

构建时请保留文件名和目录结构。长行在代码框内横向滚动。

# 项目 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)
  1. 使用相同的编译、运行、测试和清理命令。

  2. 把 EOF 与 wait 作为两个不同条件跟踪。

  3. 查看信号、exec 回退和资源继承的中文解释。

03 / process.h

公开数据与所有权约定

下载文件 ↓

构建时请保留文件名和目录结构。长行在代码框内横向滚动。

/* 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
  1. 查看容量上限与 process_result 的三个字段。

  2. 注意缓冲区与 argv 只是借用,没有转移所有权。

  3. 返回 0 表示成功回收子进程,即使子进程本身返回非零。

04 / main.c

命令行检查、字节显示与退出码映射

下载文件 ↓

构建时请保留文件名和目录结构。长行在代码框内横向滚动。

/* 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;
}
  1. parse_capacity 在分配前拒绝格式错误或越界输入。

  2. print_bytes 使用字节数而非 strlen,因此保留内部 NUL。

  3. report 解码等待状态;main 在已处理路径释放内存,并检查最终 stdout 刷新。

05 / process.c

管道配置、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;
}
  1. 先检查标准描述符,再在 fork 前创建管道,让双方都能引用。

  2. 把 execute_child 与 drain_output 分开,便于跟踪描述符所有权。

  3. 清理前保存 errno,在适当处重试 EINTR,并在读取失败后仍尝试回收。

06 / fixtures/child.c

有限的合成输出与状态生成器

下载文件 ↓

构建时请保留文件名和目录结构。长行在代码框内横向滚动。

/* 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;
}
  1. args 给每个参数加方括号,让空格边界可见。

  2. burst 写 256 个固定块;empty 与 fragment 在不依赖行的情况下检查 EOF。

  3. fail、stderr、bytes 和 signal 分别产生不同可观察行为。

07 / test.py

十四个真实进程回归测试

下载文件 ↓

构建时请保留文件名和目录结构。长行在代码框内横向滚动。

#!/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)
  1. 只把项目输入复制到 TemporaryDirectory,再在那里编译。

  2. Popen 使用参数列表、DEVNULL 输入、分离的输出管道、新会话和 8 秒看门狗。

  3. 断言把实际 stdout、stderr 和返回码与手工预期比较;超时只终止测试进程组。

08 / Makefile

严格且可重复的 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
  1. all 编译记录器和独立测试辅助程序。

  2. 明确指定 C11、POSIX 功能声明、警告和 Werror。

  3. test 调用 Python;clean 只删除两个生成的可执行文件。

每次只扩展一项契约

增加独立的 exec 错误通道

增加第二条管道,给子进程写端设置 close-on-exec。exec 成功自动关闭它,exec 失败则先写入简短 errno 记录,再 _exit。保持清晰所有权,并完整处理错误通道和 stdout。

验收检查

区分真实程序返回 127 与无法执行缺失路径;两者都回收子进程且不泄漏描述符。

把截止时间与取消作为独立功能设计

先明确进程组所有权、单调时钟截止时间、基于 poll 的读取、终止宽限期、最终强制清理,以及后代和调用者信号策略,再编码。开发时只用有限且自己拥有的测试程序。

验收检查

受控子进程超过预算时,在有界时间内产生超时结果;所有自己管理的子进程被回收、管道关闭、保存输出正确。测试不能断言精确调度时间。

一手参考资料