diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df7db6..c654f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG - shell +## Unreleased (2026-08-24) +- **进程生命周期**: + - `Process.Kill` 改为并发安全,并在返回前等待子进程回收完成。 + - 优雅退出超时后升级为 `SIGKILL`,避免旧进程占用端口时启动替代进程。 + - 异步进程 I/O 改用 `os/exec` 原生管道,避免退出期间 `Wait` 与管道关闭互相等待。 + ## v1.5.5 (2026-07-18) - **依赖校验修复**: 更新 `go.sum` 为当前可验证的依赖 checksum。 diff --git a/README.md b/README.md index 2dd97d3..67aa8cc 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ #### 数据结构 - **`Process`**: 代表由 `Start` 启动的运行中命令 - - `Kill() error`: 终止进程组(SIGTERM → 200ms → SIGKILL),Windows 直接 Kill + - `Kill() error`: 并发安全地终止进程组;先发送 SIGTERM,超时后发送 SIGKILL,返回前确保子进程已回收 - `Read(data []byte) (int, error)`: 读取缓冲的 stdout 数据(上限 512KB,超出丢弃旧数据) - `Write(data []byte) (int, error)`: 向进程 stdin 写入数据 - `Check() error`: 检查进程是否仍在运行,已退出则返回错误 diff --git a/TEST.md b/TEST.md index c17e49f..087342a 100644 --- a/TEST.md +++ b/TEST.md @@ -14,6 +14,7 @@ - '&&' 短路逻辑验证(失败中断) - 异步进程启动与管理 (Start / Process) - 进程生命周期控制 (Kill / Read / Write / Check) +- 并发调用 Kill 时只执行一次终止流程,并在返回前完成子进程回收 ## Benchmark 结果 - 目前暂未添加复杂基准测试,Shell 模块执行受限于 OS 系统调用开销。 diff --git a/run.go b/run.go index 5d21dc5..e53af30 100644 --- a/run.go +++ b/run.go @@ -1,8 +1,10 @@ package shell + import ( "apigo.cc/go/cast" "bytes" "context" + "errors" "fmt" "io" "os" @@ -119,6 +121,7 @@ func InteractiveRun(name string, args []string) error { } type writerFunc func([]byte) + func (w writerFunc) Write(p []byte) (n int, err error) { w(p) return len(p), nil @@ -238,6 +241,7 @@ type Process struct { stdoutBuf bytes.Buffer stderrBuf bytes.Buffer mu sync.Mutex + killMu sync.Mutex done chan struct{} } @@ -261,14 +265,20 @@ func Start(name string, args []string, opts *Options) (*Process, error) { } } - stdinR, stdinW := io.Pipe() - cmd.Stdin = stdinR - - stdoutR, stdoutW := io.Pipe() - cmd.Stdout = stdoutW - - stderrR, stderrW := io.Pipe() - cmd.Stderr = stderrW + stdinW, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdoutR, err := cmd.StdoutPipe() + if err != nil { + stdinW.Close() + return nil, err + } + stderrR, err := cmd.StderrPipe() + if err != nil { + stdinW.Close() + return nil, err + } if runtime.GOOS != "windows" { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} @@ -276,8 +286,6 @@ func Start(name string, args []string, opts *Options) (*Process, error) { if err := cmd.Start(); err != nil { stdinW.Close() - stdoutW.Close() - stderrW.Close() return nil, err } @@ -340,30 +348,72 @@ func Start(name string, args []string, opts *Options) (*Process, error) { go func() { cmd.Wait() stdinW.Close() - stdoutW.Close() - stderrW.Close() close(p.done) }() return p, nil } -// Kill terminates the process group. SIGTERM first for graceful shutdown, -// then SIGKILL after 200ms. On Windows, uses os.Process.Kill directly. +// Kill terminates the process group and waits until the child is reaped. +// It allows up to two seconds for graceful shutdown before sending SIGKILL. func (p *Process) Kill() error { + p.killMu.Lock() + defer p.killMu.Unlock() + if p.cmd.Process == nil { return nil } + if p.waitDone(0) { + return nil + } if runtime.GOOS == "windows" { - return p.cmd.Process.Kill() + if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + p.waitDone(-1) + return nil } pgid, err := syscall.Getpgid(p.cmd.Process.Pid) if err != nil { - return p.cmd.Process.Kill() + if errors.Is(err, syscall.ESRCH) || p.waitDone(0) { + return nil + } + return err + } + if err = syscall.Kill(-pgid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + if p.waitDone(2 * time.Second) { + return nil + } + if err = syscall.Kill(-pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + p.waitDone(-1) + return nil +} + +func (p *Process) waitDone(timeout time.Duration) bool { + if timeout < 0 { + <-p.done + return true + } + if timeout == 0 { + select { + case <-p.done: + return true + default: + return false + } + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + select { + case <-p.done: + return true + case <-ctx.Done(): + return false } - _ = syscall.Kill(-pgid, syscall.SIGTERM) - time.Sleep(200 * time.Millisecond) - return syscall.Kill(-pgid, syscall.SIGKILL) } // Read reads buffered stdout data from the process. The buffer retains up to diff --git a/run_test.go b/run_test.go index 338f0e9..e653466 100644 --- a/run_test.go +++ b/run_test.go @@ -3,6 +3,7 @@ package shell_test import ( "bytes" "strings" + "sync" "testing" "time" @@ -128,3 +129,30 @@ func TestRunCommandMixedPipeAndChain(t *testing.T) { t.Errorf("Expected 'hello' and 'world' in output, got: %s", output) } } + +func TestProcessKillWaitsAndIsConcurrentSafe(t *testing.T) { + proc, err := shell.Start("sh", []string{"-c", "while :; do read line || :; done"}, nil) + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + errs := make(chan error, 4) + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + errs <- proc.Kill() + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("Kill failed: %v", err) + } + } + if err := proc.Check(); err == nil { + t.Fatal("process should be reaped before Kill returns") + } +}