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