fix(m53): r1 评审 P2 — 小数后缀时长、文档数字、信号失败告警

P2-1 时长解析:后缀乘数只作用在整数部分,nsec 没跟着乘。
  0.05m 被算成 50ms(应 3s)、1.5m 算成 60.5s(应 90s)、0.5h 算成 0.5s。
  修法:sec 与 nsec 都乘 mult(各自带溢出检查,mult==1 行为不变)。
  --help 与头注释的示例改为逐个标出等价值(2.5s=2.5s、1.5m=90s、0.5h=1800s),
  不再留文档写着、实际算错的例子。
  新增 T11:宿主上编译 parser 单测(27 例,含 0.01m/0.05m/1.5m/0.5h)真跑,
  并单独点名评审抓到的三条,防止回归。

P2-2 文档数字:§2 产物表 47 708 → 49 676,合计 2 189 472 → 2 191 440;
  全表按 MANIFEST 重核(其余七项未漂移),§11.1 补上新的体积/md5/sha256。

P2-3 信号失败不静默:新增 kill_child()(kill(-pgid) 失败时 ESRCH 视为组已消失,
  退化为 kill(pid),EPERM 等明确告警)、verify_process_group()(发信号前用
  getpgid 核对建组是否生效)、父进程 setpgid 的异常 errno 告警;连兜底 kill
  都失败时退出码 125 而不是谎报 124。新增 -DTIMEOUT_TEST_SETPGID_FAIL 故障注入
  与 T12(a-d)验证告警路径可达且兜底真的杀掉子进程。

真机复验(md5 aded39e0,49 676 B):124/137/0/孙进程清零/保证挂住的命令 3.01s
被 124 结束/0.01m=0.61s、0.05m=3.01s、2.5s=2.51s/无参 125;收工 bt=0 sleep=0,
Music 199164544。回归 44 通过 0 失败。
This commit is contained in:
AskaEth committed 2026-11-11 21:23:52 +08:00
1 parent 24f1d8a682
commit c93814a78b
4 files changed
+314 -27

No files matched your search

+204 -12
View File
@@ -6,7 +6,7 @@
*
* 独立实现,未拷贝任何 GPL 代码。
* busybox 的 coreutils/timeout.c 与本仓 Apache-2.0 不兼容,因此本文件从零编写,
* 只依据 POSIX/Linux 系统调用语义(fork/setpgid/kill/timerfd/ppoll/signalfd)。
* 只依据 POSIX/Linux 系统调用语义(fork/setpgid/kill/setitimer/signalfd/ppoll)。
*
* 为什么设备需要它(2026-11-11 18:44 整机进程耗尽事故):设备没有 timeout(1),
* busybox 1.31.1 也无该 applet,一条挂在硬件上的外部命令(bluetoothctl 等)
@@ -17,7 +17,8 @@
* timeout [-s SIGNAL] [-k DURATION] DURATION COMMAND [ARG]...
* timeout --help
*
* DURATION 纯数字=秒;支持小数(0.2、1.5m)与 s/m/h 后缀(大小写均可)。
* DURATION 纯数字=秒;支持小数(0.2、2.5s)与 s/m/h 后缀(大小写均可),
* 小数与后缀可叠加:1.5m = 90 秒、0.5h = 1800 秒、0.01m = 0.6 秒。
* 不接受 coreutils 的 d(天)后缀,也不接受 0(与本工具用途矛盾)。
* -s SIGNAL 超时后发给整个进程组的信号,默认 TERM。可用 SIGTERM/TERM/15。
* -k DURATION 发完 -s 信号后再等 DURATION,仍未退出则 SIGKILL(两阶段)。
@@ -26,7 +27,8 @@
* 124 超时(本工具发出的信号不是 KILL,或 -k 未到期)
* 137 超时且本工具最终发了 SIGKILL(-s KILL,或 -k 到期)
* 128+N 命令自己死于信号 N(未超时;不提供 --preserve-status)
* 125 timeout 自身失败(参数错、timerfd/fork 失败、命令状态不可得)
* 125 timeout 自身失败(参数错、signalfd/setitimer/fork 失败、
* 或超时信号完全发不出去——后者会先在 stderr 明确告警)
* 126 命令存在但无法执行(EACCES 等)
* 127 命令不存在(ENOENT)
* 其它 透传命令自身的退出码
@@ -38,6 +40,14 @@
* 直接子进程一定被 waitpid 回收(不留僵尸);孙进程在组内被信号终止后
* 由 init 收养并回收。
*
* 杀不动时绝不装作成功(18:44 事故的形态就是"进程没被杀掉、越积越多"):
* - 父进程 setpgid 失败(除 EACCES/ESRCH 这些正常情形)→ stderr 告警;
* - 发信号前用 getpgid() 核对"子进程确实自成一个组",不成立就告警;
* - kill(-pgid) 返回 ESRCH(组已不存在)不算错,退化为 kill(pid, sig)
* 只杀直接子进程;返回 EPERM 等 → 明确告警 + 兜底 kill(pid);
* - 连兜底 kill 都失败(非 ESRCH)→ 退出码 125,而不是谎报 124。
* `-DTIMEOUT_TEST_SETPGID_FAIL` 可注入 setpgid 失败,用于验证上述告警路径。
*
* 计时(为什么不读时钟、也不用 timerfd):
* zig/musl 的 time64 头在 4.4 内核上 ABI 错配,clock_gettime/time 会返回垃圾值
* (本仓 M11/M16/M40/M43 的既有结论)。实测还有第二个陷阱:musl 的
@@ -103,6 +113,85 @@ static void forward_signal(int sig)
}
}
/* setpgid 的可注入版本:-DTIMEOUT_TEST_SETPGID_FAIL 用来验证"建组失败"的告警与兜底。
* 生产构建不会定义它,走的就是 libc 的 setpgid。 */
#ifdef TIMEOUT_TEST_SETPGID_FAIL
static int fake_setpgid(pid_t pid, pid_t pgid)
{
(void)pid;
(void)pgid;
errno = EPERM;
return -1;
}
#define call_setpgid fake_setpgid
#else
#define call_setpgid setpgid
#endif
/* 发信号前核对"子进程确实自成一个进程组"。
* setpgid 的失败在 fork 竞态下很难直接观测(父进程侧对已 exec 的子进程返回 EACCES
* 是正常的),所以用 getpgid 在真正要杀的那一刻做权威判定。
* 返回 1=组存在,0=组不存在/已消失(已告警或无需告警)。 */
static int verify_process_group(pid_t pid)
{
pid_t g;
errno = 0;
g = getpgid(pid);
if (g < 0) {
if (errno != ESRCH)
fprintf(stderr, "timeout: 警告: getpgid(%ld) 失败: %s\n",
(long)pid, strerror(errno));
return 0;
}
if (g != pid) {
fprintf(stderr,
"timeout: 警告: 子进程 %ld 未建立独立进程组(实际 pgid=%ld)——"
"setpgid 未生效,超时信号将尽量只发给该进程本身,它 fork 的孙进程可能残留\n",
(long)pid, (long)g);
return 0;
}
return 1;
}
/* 给"子进程组"发信号,失败时告警并退化为只杀直接子进程。
* 返回值:0=组信号已发出;1=组不存在,退化为直接子进程信号;2=目标已消失(无残留);
* -1=组与进程都没能收到信号(应让 timeout 自身以 125 退出,不能谎报 124)。 */
static int kill_child(pid_t pid, int sig, const char *phase)
{
if (kill(-pid, sig) == 0)
return 0;
{
int e = errno;
if (e == ESRCH) {
/* 组已不存在:子进程可能已退出,也可能 setpgid 没成功。不是错误。 */
if (kill(pid, sig) == 0) {
fprintf(stderr,
"timeout: 警告: %s: 进程组 %ld 不存在,已退化为只给直接子进程 %ld 发信号 %d\n",
phase, (long)pid, (long)pid, sig);
return 1;
}
if (errno == ESRCH)
return 2;
fprintf(stderr, "timeout: 警告: %s: 信号 %d 发不出去: %s(组与进程都不可达)\n",
phase, sig, strerror(errno));
return -1;
}
fprintf(stderr, "timeout: 警告: %s: kill(-%ld, %d) 失败: %s\n",
phase, (long)pid, sig, strerror(e));
if (kill(pid, sig) == 0)
return 1;
if (errno == ESRCH)
return 2;
fprintf(stderr, "timeout: 警告: %s: 连直接子进程 %ld 也杀不掉: %s\n",
phase, (long)pid, strerror(errno));
return -1;
}
}
struct sig_name {
const char *name;
int num;
@@ -126,7 +215,8 @@ static void usage(FILE *fp)
fputs(
"用法: timeout [-s SIGNAL] [-k DURATION] DURATION COMMAND [ARG]...\n"
"\n"
" DURATION 等待时长:纯数字为秒,可带小数,后缀 s/m/h(如 2、0.2、1.5m)\n"
" DURATION 等待时长:纯数字为秒,可带小数,后缀 s/m/h\n"
" (2=2s、0.2=200ms、2.5s=2.5s、1.5m=90s、0.5h=1800s)\n"
" -s SIGNAL 超时后发给整个进程组的信号,默认 TERM(可写 SIGTERM/TERM/15)\n"
" -k DURATION 发出 -s 信号后再等这么久,仍未退出则发 KILL(两阶段终止)\n"
" -h, --help 显示本帮助\n"
@@ -212,10 +302,16 @@ static int parse_duration(const char *s, uint64_t *out_ns)
if (s[i] != '\0')
return -1;
/* 后缀乘数是"整个时长"的,小数部分必须一起乘:
* 0.05m = 0.05 分钟 = 3 秒,1.5m = 90 秒,0.5h = 1800 秒。
* (只乘整数部分会让 0.05m 变成 50 毫秒——比要求更早杀掉,静默给错答案。) */
if (mult != 1) {
if (sec > UINT64_MAX / mult)
return -1;
sec *= mult;
if (nsec > UINT64_MAX / mult)
return -1;
nsec *= mult;
}
if (sec > (UINT64_MAX - nsec) / NSEC_PER_SEC)
return -1;
@@ -256,6 +352,12 @@ static int duration_arg(const char *opt, const char *val, uint64_t *ns)
return 0;
}
/* 单元测试模式:把真正的 main 改名,文件末尾注入一个只测 parse_duration 的 main。
* 生产构建不定义 TIMEOUT_UNIT_TEST,二进制里没有这些测试代码。 */
#ifdef TIMEOUT_UNIT_TEST
#define main timeout_main
#endif
int main(int argc, char **argv)
{
int kill_signal = SIGTERM;
@@ -270,7 +372,7 @@ int main(int argc, char **argv)
sigset_t wait_mask, orig_mask;
struct pollfd pfd;
int status = 0;
int timed_out = 0, killed_by_us = 0, reaped = 0, done = 0;
int timed_out = 0, killed_by_us = 0, reaped = 0, done = 0, signal_failed = 0;
while (i < argc) {
const char *a = argv[i];
@@ -371,8 +473,10 @@ int main(int argc, char **argv)
}
if (pid == 0) {
/* 自成进程组:超时信号 kill(-pgid) 才能覆盖孙进程 */
(void)setpgid(0, 0);
/* 自成进程组:超时信号 kill(-pgid) 才能覆盖孙进程。
* 这里失败不直接报错(父进程会再做一次;发信号前还有 getpgid 核对),
* 因为子进程此时还没 exec,直接写 stderr 会和命令自身的输出混在一起。 */
(void)call_setpgid(0, 0);
for (size_t k = 0; k < sizeof(forward_sigs) / sizeof(forward_sigs[0]); k++)
(void)signal(forward_sigs[k], SIG_DFL);
(void)sigprocmask(SIG_SETMASK, &orig_mask, NULL);
@@ -392,13 +496,22 @@ int main(int argc, char **argv)
}
g_child_pgid = pid;
/* fork 与子进程 setpgid 之间的竞态由父进程这一侧兜住(任一侧成功即可) */
(void)setpgid(pid, pid);
/* fork 与子进程 setpgid 之间的竞态由父进程这一侧兜住(任一侧成功即可)。
* EACCES = 子进程已经 exec(它自己那次 setpgid 已经跑过),ESRCH = 已退出,
* 这两种是正常竞态,不当错误;其它 errno 说明建组可能失败,要告警。 */
if (call_setpgid(pid, pid) != 0) {
int e = errno;
if (e != EACCES && e != ESRCH)
fprintf(stderr,
"timeout: 警告: setpgid(%ld) 失败: %s —— 超时信号可能只能发给该进程本身\n",
(long)pid, strerror(e));
}
/* 若在 fork 与 g_child_pgid 赋值之间收到过转发信号,这里补发一次 */
if (g_pending_sig != 0) {
int sig = (int)g_pending_sig;
(void)kill(-pid, sig);
if (kill_child(pid, sig, "转发信号") < 0)
signal_failed = 1;
g_pending_sig = 0;
}
@@ -431,8 +544,12 @@ int main(int argc, char **argv)
if (si.ssi_signo == SIGALRM) {
if (!timed_out) {
timed_out = 1;
/* 真正要杀之前核对一次进程组:setpgid 若没生效,这里必须告警,
* 不能让用户看到 124 却以为孙进程都被杀了。 */
(void)verify_process_group(pid);
/* 先发温和信号(默认 TERM),发给整个进程组 */
(void)kill(-pid, kill_signal);
if (kill_child(pid, kill_signal, "超时") < 0)
signal_failed = 1;
if (kill_signal == SIGKILL) {
killed_by_us = 1;
disarm_timer();
@@ -443,7 +560,8 @@ int main(int argc, char **argv)
}
} else {
/* -k 到期:KILL 整个组,仍继续 waitpid 回收直接子进程 */
(void)kill(-pid, SIGKILL);
if (kill_child(pid, SIGKILL, "-k 到期") < 0)
signal_failed = 1;
killed_by_us = 1;
disarm_timer();
}
@@ -478,6 +596,10 @@ int main(int argc, char **argv)
return EXIT_FAILURE_125;
if (timed_out) {
/* 信号完全发不出去时不能谎报 124:那是"以为杀了、其实没杀"的形态,
* 正是 2026-11-11 事故的样子。已在 stderr 告警,退出码用 125。 */
if (signal_failed)
return EXIT_FAILURE_125;
/* 超时:最终发了 KILL 才报 137,否则 124(coreutils 语义) */
return killed_by_us ? 128 + SIGKILL : EXIT_TIMEOUT;
}
@@ -487,3 +609,73 @@ int main(int argc, char **argv)
return 128 + WTERMSIG(status);
return EXIT_FAILURE_125;
}
#ifdef TIMEOUT_UNIT_TEST
#undef main
/* 时长解析的单元测试。在宿主上用 `cc -DTIMEOUT_UNIT_TEST` 编译后直接跑,
* 不需要 sleep、不依赖目标设备——所以 test_build_devkit.sh 能把它当回归用例。
* 覆盖重点:小数 + s/m/h 后缀叠加(0.05m=3s、1.5m=90s、0.5h=1800s),
* 这正是 r1 评审抓到的 P2(只乘了整数部分,0.05m 被当成 50ms)。 */
struct dur_case {
const char *text;
uint64_t want_ns;
int want_ok;
};
static const struct dur_case dur_cases[] = {
{ "2", 2ULL * NSEC_PER_SEC, 1 },
{ "2s", 2ULL * NSEC_PER_SEC, 1 },
{ "2S", 2ULL * NSEC_PER_SEC, 1 },
{ "10", 10ULL * NSEC_PER_SEC, 1 },
{ ".5", 500000000ULL, 1 },
{ "0.2", 200000000ULL, 1 },
{ "2.5s", 2500000000ULL, 1 },
{ "1.5", 1500000000ULL, 1 },
{ "0.000000001", 1ULL, 1 },
{ "1m", 60ULL * NSEC_PER_SEC, 1 },
{ "1.5m", 90ULL * NSEC_PER_SEC, 1 },
{ "0.01m", 600000000ULL, 1 },
{ "0.05m", 3000000000ULL, 1 },
{ "0.5M", 30ULL * NSEC_PER_SEC, 1 },
{ "0.5h", 1800ULL * NSEC_PER_SEC, 1 },
{ "1h", 3600ULL * NSEC_PER_SEC, 1 },
{ "0.25h", 900ULL * NSEC_PER_SEC, 1 },
{ "0", 0, 0 },
{ "", 0, 0 },
{ "1d", 0, 0 },
{ "abc", 0, 0 },
{ "1x", 0, 0 },
{ "1.2.3", 0, 0 },
{ "-1", 0, 0 },
{ " 1", 0, 0 },
{ "18446744074", 0, 0 }, /* sec*1e9 溢出 */
{ "99999999999999999999h", 0, 0 }, /* 整数解析溢出 */
};
int main(void)
{
size_t i;
int bad = 0;
const size_t n = sizeof(dur_cases) / sizeof(dur_cases[0]);
for (i = 0; i < n; i++) {
uint64_t got = 0;
int ok = (parse_duration(dur_cases[i].text, &got) == 0);
if (ok != dur_cases[i].want_ok ||
(ok && got != dur_cases[i].want_ns)) {
printf("FAIL DURATION '%s': rc=%d ns=%llu, want rc=%d ns=%llu\n",
dur_cases[i].text, ok, (unsigned long long)got,
dur_cases[i].want_ok, (unsigned long long)dur_cases[i].want_ns);
bad++;
} else {
printf("ok DURATION '%-24s' -> %s%llu ns\n",
dur_cases[i].text, ok ? "" : "INVALID ",
ok ? (unsigned long long)got : 0ULL);
}
}
printf("DURATION: %zu cases, %d failed\n", n, bad);
return bad ? 1 : 0;
}
#endif