// signal_group_test.go - DefaultExecutor.SignalGroup 的行为单测. // // 重点验证两条: (1) isolateGroup=true 下 SIGTERM 真的作用于 pgid, // (2) isolateGroup=false 下精确退化为单进程 Signal — 这是最危险的 // 边界, 写错了会让 engine 宿主自杀, 必须有测试锁住. // // Linux/Mac only. 依赖 /bin/sh + sleep, 和 executor_bench_test.go // 的 POSIX-only 约束一致. package execenv import ( "context" "errors" "os" "syscall" "testing" "time" ) // TestSignalGroup_IsolatedPgidReceivesSignal 证明 isolateGroup=true 时 // SignalGroup 能杀掉整个进程树 — 起一个 sh 派生 sleep 子进程, SignalGroup // 发 SIGTERM, 子孙进程都应该被一起带走. func TestSignalGroup_IsolatedPgidReceivesSignal(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // sh -c 'sleep 30' 会让 sh fork 出一个 sleep 孙进程. 如果 SignalGroup // 只作用叶子 sh 不作用 sleep, sleep 会成为孤儿继续跑 30s, Wait 会 // block 直到 ctx 超时; 反之会立刻返回. spec := Spec{ Class: ClassBash, Path: "/bin/sh", Args: []string{"-c", "sleep 30"}, IsolateProcessGroup: true, } p := DefaultExecutor{}.Command(ctx, spec) if err := p.Start(); err != nil { t.Fatalf("Start: %v", err) } // 给 pgid 设置一点时间生效 (Setpgid 是 Start 之后才实际生效的). time.Sleep(50 * time.Millisecond) if err := p.SignalGroup(syscall.SIGTERM); err != nil { t.Fatalf("SignalGroup: %v", err) } done := make(chan error, 1) go func() { done <- p.Wait() }() select { case <-done: // OK - Wait 已返回, 说明信号确实到位了. case <-time.After(3 * time.Second): t.Fatal("Wait timed out; SignalGroup(SIGTERM) did not terminate the child tree") } } // TestSignalGroup_NonIsolatedFallsBackToSingleProcess 锁住最危险边界: // isolateGroup=false 时必须精确退化为 p.cmd.Process.Signal(sig), 不能 // 走 syscall.Kill(-pgid, sig). 如果走 group 路径, 子进程继承父 pgid, // kill(-父 pgid) 会把 go test 进程自己杀掉 — 这个测试用存活来证明. // // 做法: 起 sleep 100, 不设 isolateGroup, SignalGroup 发 signal 0 (探活). // 如果实现错误走 syscall.Kill(-pgid, 0), 作用对象是测试进程所在的 pgid, // signal 0 只做探活不真杀, 所以本测试即使实现错误也不会让 harness 崩掉; // 但我们可以用 "signal 0 到非 syscall.Signal 的退化路径" 的间接证据 + // 成功状态码验证 —— 更直接的做法是换一个明确"只能作用于子进程"的观察. // // 更简单的观察: 起 sleep, SignalGroup(SIGTERM), 等 Wait 返回, 验证 // 返回的是 SIGTERM 相关的 exit error. 单进程 Signal 也会让 sleep 被 // SIGTERM 终止, Wait 返回 signal: terminated, 行为正确. 这恰恰是退化 // 路径该有的行为, 测试用 "Wait 返回 + test 进程自己没死" 作为断言. func TestSignalGroup_NonIsolatedFallsBackToSingleProcess(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() spec := Spec{ Class: ClassBash, Path: "/bin/sleep", Args: []string{"30"}, // 故意不设 IsolateProcessGroup } p := DefaultExecutor{}.Command(ctx, spec) if err := p.Start(); err != nil { t.Fatalf("Start: %v", err) } if err := p.SignalGroup(syscall.SIGTERM); err != nil { t.Fatalf("SignalGroup: %v", err) } done := make(chan error, 1) go func() { done <- p.Wait() }() select { case err := <-done: // 期望: sleep 被 SIGTERM 终止, Wait 返回非 nil exit error. if err == nil { t.Fatal("expected non-nil error from Wait after SIGTERM, got nil") } case <-time.After(3 * time.Second): t.Fatal("Wait timed out; fallback Signal path did not reach the child") } // 能执行到这里意味着 go test 进程自己没被杀, 退化路径正确. } // TestSignalGroup_UnstartedReturnsProcessDone 锁住幂等边界: 未 Start // 的 Process 上调用 SignalGroup 返回 os.ErrProcessDone, 和 Signal 对齐. func TestSignalGroup_UnstartedReturnsProcessDone(t *testing.T) { spec := Spec{ Class: ClassBash, Path: "/bin/true", } p := DefaultExecutor{}.Command(context.Background(), spec) err := p.SignalGroup(syscall.SIGTERM) if !errors.Is(err, os.ErrProcessDone) { t.Fatalf("expected os.ErrProcessDone on un-started process, got %v", err) } }