fix(shell): 确保异步进程终止后完成回收(by AI)

This commit is contained in:
Star 2026-08-24 11:24:25 +08:00
parent 6623375c28
commit c90468a9d0
5 changed files with 105 additions and 20 deletions

View File

@ -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。

View File

@ -51,7 +51,7 @@
#### 数据结构
- **`Process`**: 代表由 `Start` 启动的运行中命令
- `Kill() error`: 终止进程组SIGTERM → 200ms → SIGKILLWindows 直接 Kill
- `Kill() error`: 并发安全地终止进程组;先发送 SIGTERM超时后发送 SIGKILL返回前确保子进程已回收
- `Read(data []byte) (int, error)`: 读取缓冲的 stdout 数据(上限 512KB超出丢弃旧数据
- `Write(data []byte) (int, error)`: 向进程 stdin 写入数据
- `Check() error`: 检查进程是否仍在运行,已退出则返回错误

View File

@ -14,6 +14,7 @@
- '&&' 短路逻辑验证(失败中断)
- 异步进程启动与管理 (Start / Process)
- 进程生命周期控制 (Kill / Read / Write / Check)
- 并发调用 Kill 时只执行一次终止流程,并在返回前完成子进程回收
## Benchmark 结果
- 目前暂未添加复杂基准测试Shell 模块执行受限于 OS 系统调用开销。

88
run.go
View File

@ -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

View File

@ -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")
}
}