Compare commits

..

No commits in common. "main" and "v1.5.5" have entirely different histories.
main ... v1.5.5

7 changed files with 23 additions and 114 deletions

View File

@ -1,12 +1,5 @@
# CHANGELOG - shell # CHANGELOG - shell
## v1.5.6 (2026-08-24)
- **进程生命周期**:
- `Process.Kill` 改为并发安全,并在返回前等待子进程回收完成。
- 优雅退出超时后升级为 `SIGKILL`,避免旧进程占用端口时启动替代进程。
- 异步进程 I/O 改用 `os/exec` 原生管道,避免退出期间 `Wait` 与管道关闭互相等待。
- **依赖更新**: 对齐 `cast v1.5.5`
## v1.5.5 (2026-07-18) ## v1.5.5 (2026-07-18)
- **依赖校验修复**: 更新 `go.sum` 为当前可验证的依赖 checksum。 - **依赖校验修复**: 更新 `go.sum` 为当前可验证的依赖 checksum。

View File

@ -51,7 +51,7 @@
#### 数据结构 #### 数据结构
- **`Process`**: 代表由 `Start` 启动的运行中命令 - **`Process`**: 代表由 `Start` 启动的运行中命令
- `Kill() error`: 并发安全地终止进程组;先发送 SIGTERM超时后发送 SIGKILL返回前确保子进程已回收 - `Kill() error`: 终止进程组SIGTERM → 200ms → SIGKILLWindows 直接 Kill
- `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`: 检查进程是否仍在运行,已退出则返回错误

View File

@ -1,10 +1,5 @@
# 测试报告 (shell) # 测试报告 (shell)
## v1.5.6 验证
- 验证 `Process.Kill` 并发调用只执行一次终止流程。
- 验证 `Kill` 返回前子进程已完成回收。
- 全量测试、竞态测试和基准测试均通过。
## v1.5.5 验证 ## v1.5.5 验证
- 独立模块解析、全量测试与基准测试均通过。 - 独立模块解析、全量测试与基准测试均通过。
@ -19,7 +14,6 @@
- '&&' 短路逻辑验证(失败中断) - '&&' 短路逻辑验证(失败中断)
- 异步进程启动与管理 (Start / Process) - 异步进程启动与管理 (Start / Process)
- 进程生命周期控制 (Kill / Read / Write / Check) - 进程生命周期控制 (Kill / Read / Write / Check)
- 并发调用 Kill 时只执行一次终止流程,并在返回前完成子进程回收
## Benchmark 结果 ## Benchmark 结果
- 目前暂未添加复杂基准测试Shell 模块执行受限于 OS 系统调用开销。 - 目前暂未添加复杂基准测试Shell 模块执行受限于 OS 系统调用开销。

2
go.mod
View File

@ -2,6 +2,6 @@ module apigo.cc/go/shell
go 1.25.0 go 1.25.0
require apigo.cc/go/cast v1.5.5 require apigo.cc/go/cast v1.5.3
require apigo.cc/go/jsmod v1.5.3 require apigo.cc/go/jsmod v1.5.3

4
go.sum
View File

@ -1,4 +1,4 @@
apigo.cc/go/cast v1.5.5 h1:DMbfK3uPhPjRaXutj3StIZIkfjFIATSXfuAOeNOd4Fw= apigo.cc/go/cast v1.5.3 h1:jk6VX0rGFhjKtfPhsaV6IKYpiGmORRk9qPTtuNS53tw=
apigo.cc/go/cast v1.5.5/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4= apigo.cc/go/cast v1.5.3/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4=
apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU= apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU=
apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw= apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=

88
run.go
View File

@ -1,10 +1,8 @@
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"
@ -121,7 +119,6 @@ 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
@ -241,7 +238,6 @@ 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{}
} }
@ -265,20 +261,14 @@ func Start(name string, args []string, opts *Options) (*Process, error) {
} }
} }
stdinW, err := cmd.StdinPipe() stdinR, stdinW := io.Pipe()
if err != nil { cmd.Stdin = stdinR
return nil, err
} stdoutR, stdoutW := io.Pipe()
stdoutR, err := cmd.StdoutPipe() cmd.Stdout = stdoutW
if err != nil {
stdinW.Close() stderrR, stderrW := io.Pipe()
return nil, err cmd.Stderr = stderrW
}
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}
@ -286,6 +276,8 @@ 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
} }
@ -348,72 +340,30 @@ 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 and waits until the child is reaped. // Kill terminates the process group. SIGTERM first for graceful shutdown,
// It allows up to two seconds for graceful shutdown before sending SIGKILL. // then SIGKILL after 200ms. On Windows, uses os.Process.Kill directly.
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" {
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { return p.cmd.Process.Kill()
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 {
if errors.Is(err, syscall.ESRCH) || p.waitDone(0) { return p.cmd.Process.Kill()
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

View File

@ -3,7 +3,6 @@ package shell_test
import ( import (
"bytes" "bytes"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@ -129,30 +128,3 @@ 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")
}
}