修复 frpc 进程生命周期真 bug(agent 生产代码):exec.CommandContext 默认只杀直接子进程,被杀 shell 的孤儿 sleep 继续持有 stderr 管道,cmd.Wait() 阻塞到其自然退出(Stop/重启可挂起秒级)。改 Setpgid 进程组 + Kill(-pid) 整组击杀。连带修复两个测试 bug(Manager 拥有 Cmd 的并发 Wait 竞态 → Signal(0) 探测;ssl_renew 用 miniredis 替代 init() 创建的真实 redis 客户端)。go test ./internal/... ./pkg/... 全绿,checks.sh 升级为真实测试门禁。

Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":40}
This commit is contained in:
ryan
2026-08-16 18:47:46 +08:00
parent c85373ff47
commit a16be014d4
5 changed files with 56 additions and 12 deletions
+11
View File
@@ -195,6 +195,17 @@ func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
// frpc 及其中间子进程必须整体随上下文终止:CommandContext 默认只杀
// 直接子进程,孤儿孙进程会继续持有 stderr 管道导致 cmd.Wait 阻塞到其
// 自然退出。这里为 frpc 单独建进程组并整组 SIGKILL。
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
m.mu.Lock()
proc.Cmd = cmd
proc.Status = "running"
+19 -1
View File
@@ -7,6 +7,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
@@ -80,6 +81,7 @@ func assertStatusEventually(t *testing.T, m *Manager, relayID string, expectedSt
t.Fatalf("expected status eventually %s, got %s (err: %s)", expectedStatus, got, errStr)
}
// assertCommandExitedEventually 等待测试自建进程退出(本测试持有其 Wait 权)。
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
t.Helper()
@@ -95,6 +97,22 @@ func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Dur
}
}
// assertManagedCommandExitedEventually 探测受管进程是否已退出。不能对其调用
// Wait —— Wait 由 Manager 拥有,测试并发 Wait 会与 os/exec 的 ctxResult
// 通道竞争而永久挂起;Signal(0) 在进程被 Manager 收割后即报错。
func assertManagedCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if err := cmd.Process.Signal(syscall.Signal(0)); err != nil {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("expected managed process pid=%d to exit within %s", cmd.Process.Pid, timeout)
}
func TestStartProcessSuccess(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5) // exit code 0, sleep 5s
@@ -380,7 +398,7 @@ func TestStopCancelsRunningProcesses(t *testing.T) {
m.mu.RUnlock()
m.Stop()
assertCommandExitedEventually(t, cmd, 2*time.Second)
assertManagedCommandExitedEventually(t, cmd, 2*time.Second)
m.mu.RLock()
defer m.mu.RUnlock()
+15 -11
View File
@@ -8,17 +8,16 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
"github.com/Rain-kl/Wavelet/internal/infra/config"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/hibiken/asynq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupSSLRenewTestDB(t *testing.T) func() {
@@ -26,18 +25,23 @@ func setupSSLRenewTestDB(t *testing.T) func() {
task.RegisterTaskMeta(tls.SSLSingleRenewMeta)
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.TLSCertificate{}, &model.TaskExecution{}))
_, mr, cleanup := testhelper.SetupTestEnvironment(t)
require.NoError(t, db.DB(nil).AutoMigrate(&model.TLSCertificate{}, &model.TaskExecution{}))
// task 包 init() 会按配置创建指向真实 Redis 的客户端;测试显式改用
// miniredis(与 executor_test 一致),避免依赖本地 redis 实例。
oldClient := task.AsynqClient
task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()})
t.Cleanup(func() {
_ = task.AsynqClient.Close()
task.AsynqClient = oldClient
})
db.SetDB(sqliteDB)
oldSecret := config.Config.App.SessionSecret
config.Config.App.SessionSecret = "test_session_secret_for_ssl_renew"
return func() {
db.SetDB(nil)
config.Config.App.SessionSecret = oldSecret
cleanup()
}
}