启动项目逻辑

This commit is contained in:
2026-06-09 22:43:29 +08:00
parent d5a28b11a3
commit f4a7c770af
43 changed files with 3797 additions and 42 deletions
+39
View File
@@ -0,0 +1,39 @@
// Package proc 提供跨平台的"进程组/进程树"终止抽象。一个 Group 把某个进程及其
// 派生的所有子孙进程作为单一单元管理,使 stop/restart/crash 时能整树清理,避免
// dev server(如 pnpm dev → node/vite/esbuild)留下占用端口的孤儿进程。
//
// 用法与 *exec.Cmd 生命周期对齐:
//
// g := proc.NewGroup()
// g.Prepare(cmd) // cmd.Start() 之前:Unix 设置 SysProcAttr.Setpgid
// cmd.Start()
// g.Adopt(cmd) // cmd.Start() 之后:Windows 创建 Job 并 Assign
// ...
// g.TerminateGroup(done, grace) // 整树优雅终止
// g.Close() // 释放平台句柄
package proc
import (
"os/exec"
"time"
)
// Group 抽象"一个进程及其所有子孙进程"作为单一单元管理。
// 非并发安全:调用方需保证 Prepare/Adopt/TerminateGroup/Close 的串行使用
// (RunSupervisor / ACP Supervisor 已用各自的锁与 done channel 保证)。
type Group interface {
// Prepare 在 cmd.Start() 之前设置平台属性(Unix: SysProcAttr.Setpgid)。
Prepare(cmd *exec.Cmd)
// Adopt 在 cmd.Start() 之后接管已启动的进程(Windows: 创建 Job 并
// AssignProcessToJobObject)。Unix 为记录 pgid。必须在子进程派生前调用。
Adopt(cmd *exec.Cmd) error
// TerminateGroup 优雅终止整组:Unix 先 SIGTERM(-pgid) 等 grace 再 SIGKILL(-pgid);
// Windows CloseHandle(job) 触发 KILL_ON_JOB_CLOSE 整树终止。
// done 由调用方(monitor goroutine)关闭,用于感知优雅退出、提前返回。
TerminateGroup(done <-chan struct{}, grace time.Duration)
// Close 释放平台句柄(Windows: 关闭 Job handle;Unix: no-op)。幂等。
Close()
}
// NewGroup 按编译期 build tag 返回 unixGroup / windowsGroup。
func NewGroup() Group { return newGroup() }
+54
View File
@@ -0,0 +1,54 @@
package proc
import (
"fmt"
"os/exec"
"runtime"
"testing"
"time"
)
// sleeperCmd 返回一个会阻塞约 d 秒的跨平台命令。
func sleeperCmd(d int) *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/c", fmt.Sprintf("timeout /t %d /nobreak >nul", d))
}
return exec.Command("sh", "-c", fmt.Sprintf("sleep %d", d))
}
// TestGroup_TerminateKillsStartedProcess 验证完整生命周期:Prepare → Start →
// Adopt → TerminateGroup 能在 grace 内终止一个本应长跑的进程。
func TestGroup_TerminateKillsStartedProcess(t *testing.T) {
g := NewGroup()
cmd := sleeperCmd(30)
g.Prepare(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
if err := g.Adopt(cmd); err != nil {
t.Fatalf("adopt: %v", err)
}
defer g.Close()
done := make(chan struct{})
go func() {
_ = cmd.Wait()
close(done)
}()
g.TerminateGroup(done, 2*time.Second)
select {
case <-done:
// 进程已终止,符合预期。
case <-time.After(10 * time.Second):
t.Fatal("process not terminated within 10s after TerminateGroup")
}
}
// TestNewGroup_NotNil 守护 build tag 选择逻辑:任意平台都应返回非 nil Group。
func TestNewGroup_NotNil(t *testing.T) {
if NewGroup() == nil {
t.Fatal("NewGroup returned nil")
}
}
+50
View File
@@ -0,0 +1,50 @@
//go:build !windows
package proc
import (
"errors"
"os/exec"
"syscall"
"time"
)
// unixGroup 用 POSIX 进程组实现整树终止:子进程经 Setpgid 成为新进程组组长
// (pgid == pid),向负 pgid 发信号即覆盖整个进程组的所有子孙。
type unixGroup struct {
pgid int
}
func newGroup() Group { return &unixGroup{} }
func (g *unixGroup) Prepare(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
func (g *unixGroup) Adopt(cmd *exec.Cmd) error {
if cmd.Process == nil {
return errors.New("proc: Adopt before Start")
}
// Setpgid 使子进程成为新进程组组长,pgid == pid。
g.pgid = cmd.Process.Pid
return nil
}
func (g *unixGroup) TerminateGroup(done <-chan struct{}, grace time.Duration) {
if g.pgid <= 0 {
return
}
// 向负 pgid 发信号 = 向整个进程组(含所有子孙)发信号。忽略 ESRCH 等错误。
_ = syscall.Kill(-g.pgid, syscall.SIGTERM)
select {
case <-done:
return
case <-time.After(grace):
_ = syscall.Kill(-g.pgid, syscall.SIGKILL)
}
}
func (g *unixGroup) Close() {}
+65
View File
@@ -0,0 +1,65 @@
//go:build !windows
package proc
import (
"fmt"
"os/exec"
"syscall"
"testing"
"time"
)
// TestUnixGroup_KillsChildProcess 验证整树清理:父 shell 派生一个后台 sleep
// 子进程并打印其 PID,TerminateGroup 后该子进程(孙级)也应被 kill(-pgid) 回收,
// 证明终止覆盖整个进程组而非仅组长。
func TestUnixGroup_KillsChildProcess(t *testing.T) {
g := NewGroup()
// `sleep 30 &` 后台子进程;`echo $!` 打印其 PID;`wait` 让 shell 阻塞存活。
cmd := exec.Command("sh", "-c", "sleep 30 & echo $!; wait")
g.Prepare(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
if err := g.Adopt(cmd); err != nil {
t.Fatalf("adopt: %v", err)
}
defer g.Close()
var childPID int
if _, err := fmt.Fscan(stdout, &childPID); err != nil {
t.Fatalf("read child pid: %v", err)
}
if childPID <= 0 {
t.Fatalf("invalid child pid: %d", childPID)
}
done := make(chan struct{})
go func() {
_ = cmd.Wait()
close(done)
}()
g.TerminateGroup(done, 2*time.Second)
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("parent not terminated")
}
// 给内核一点时间回收子进程,然后用 kill(pid, 0) 探测:返回错误(ESRCH)=已死。
deadline := time.Now().Add(5 * time.Second)
for {
if err := syscall.Kill(childPID, 0); err != nil {
return // 子进程已死,符合预期。
}
if time.Now().After(deadline) {
t.Fatalf("child process %d still alive after group terminate", childPID)
}
time.Sleep(100 * time.Millisecond)
}
}
+73
View File
@@ -0,0 +1,73 @@
//go:build windows
package proc
import (
"errors"
"os/exec"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// windowsGroup 用 Job Object 实现整树终止:把进程纳入一个设置了
// KILL_ON_JOB_CLOSE 的 Job,此后该进程派生的所有子孙自动归入同一 Job,
// 关闭 Job 句柄即整树终止。优于 Process.Kill(只杀单 PID,漏 node 等子进程)。
type windowsGroup struct {
job windows.Handle
}
func newGroup() Group { return &windowsGroup{} }
// Prepare 在 Windows 为 no-op:Job 须在进程 Start 之后才能 Assign。
func (g *windowsGroup) Prepare(cmd *exec.Cmd) {}
// Adopt 必须在 cmd.Start() 之后、子进程派生之前调用。竞态窗口:父进程 exec 后
// 到首个子进程派生之间存在微秒级窗口(Go 无 pre-exec hook 无法完全消除),
// 实践风险极小,文档化为已知限制。
func (g *windowsGroup) Adopt(cmd *exec.Cmd) error {
if cmd.Process == nil {
return errors.New("proc: Adopt before Start")
}
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return err
}
var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
); err != nil {
_ = windows.CloseHandle(job)
return err
}
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
if err != nil {
_ = windows.CloseHandle(job)
return err
}
defer windows.CloseHandle(h)
if err := windows.AssignProcessToJobObject(job, h); err != nil {
_ = windows.CloseHandle(job)
return err
}
g.job = job
return nil
}
// TerminateGroup 在 Windows 无可靠 SIGTERM 等价物,直接关闭 Job 句柄触发
// KILL_ON_JOB_CLOSE 整树终止(grace/done 不参与,仅保持接口签名一致)。
func (g *windowsGroup) TerminateGroup(_ <-chan struct{}, _ time.Duration) {
g.Close()
}
func (g *windowsGroup) Close() {
if g.job != 0 {
_ = windows.CloseHandle(g.job)
g.job = 0
}
}