From a16be014d4a36e75678df810422885833ebf80c7 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 18:47:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20frpc=20=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=9C=9F=20bug=EF=BC=88age?= =?UTF-8?q?nt=20=E7=94=9F=E4=BA=A7=E4=BB=A3=E7=A0=81=EF=BC=89=EF=BC=9Aexec?= =?UTF-8?q?.CommandContext=20=E9=BB=98=E8=AE=A4=E5=8F=AA=E6=9D=80=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=AD=90=E8=BF=9B=E7=A8=8B=EF=BC=8C=E8=A2=AB=E6=9D=80?= =?UTF-8?q?=20shell=20=E7=9A=84=E5=AD=A4=E5=84=BF=20sleep=20=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E6=8C=81=E6=9C=89=20stderr=20=E7=AE=A1=E9=81=93?= =?UTF-8?q?=EF=BC=8Ccmd.Wait()=20=E9=98=BB=E5=A1=9E=E5=88=B0=E5=85=B6?= =?UTF-8?q?=E8=87=AA=E7=84=B6=E9=80=80=E5=87=BA=EF=BC=88Stop/=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E5=8F=AF=E6=8C=82=E8=B5=B7=E7=A7=92=E7=BA=A7=EF=BC=89?= =?UTF-8?q?=E3=80=82=E6=94=B9=20Setpgid=20=E8=BF=9B=E7=A8=8B=E7=BB=84=20+?= =?UTF-8?q?=20Kill(-pid)=20=E6=95=B4=E7=BB=84=E5=87=BB=E6=9D=80=E3=80=82?= =?UTF-8?q?=E8=BF=9E=E5=B8=A6=E4=BF=AE=E5=A4=8D=E4=B8=A4=E4=B8=AA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20bug=EF=BC=88Manager=20=E6=8B=A5=E6=9C=89=20Cmd=20?= =?UTF-8?q?=E7=9A=84=E5=B9=B6=E5=8F=91=20Wait=20=E7=AB=9E=E6=80=81=20?= =?UTF-8?q?=E2=86=92=20Signal(0)=20=E6=8E=A2=E6=B5=8B=EF=BC=9Bssl=5Frenew?= =?UTF-8?q?=20=E7=94=A8=20miniredis=20=E6=9B=BF=E4=BB=A3=20init()=20?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E7=9A=84=E7=9C=9F=E5=AE=9E=20redis=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=EF=BC=89=E3=80=82go=20test=20./inte?= =?UTF-8?q?rnal/...=20./pkg/...=20=E5=85=A8=E7=BB=BF=EF=BC=8Cchecks.sh=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E4=B8=BA=E7=9C=9F=E5=AE=9E=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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} --- .auto/checks.sh | 10 +++++++ .auto/log.jsonl | 1 + internal/apps/flared/frpc/manager.go | 11 ++++++++ internal/apps/flared/frpc/manager_test.go | 20 +++++++++++++- .../apps/openflare/tasks/ssl_renew_test.go | 26 +++++++++++-------- 5 files changed, 56 insertions(+), 12 deletions(-) diff --git a/.auto/checks.sh b/.auto/checks.sh index eb59aed4..b052409d 100755 --- a/.auto/checks.sh +++ b/.auto/checks.sh @@ -12,4 +12,14 @@ go build ./... 2>&1 | tail -20 echo "==> golangci-lint run (repo config)" golangci-lint run 2>&1 | tail -20 +# 全量单测(sqlite + miniredis,纯本地无需外部服务;2026-08-16 起全绿) +echo "==> go test ./internal/... ./pkg/..." +go test ./internal/... ./pkg/... 2>&1 | grep -E "^--- FAIL|^FAIL" | head -20 || true +if go test ./internal/... ./pkg/... > /tmp/auto_gotest.log 2>&1; then + : +else + tail -30 /tmp/auto_gotest.log + exit 1 +fi + echo "OK: checks passed" \ No newline at end of file diff --git a/.auto/log.jsonl b/.auto/log.jsonl index 4f41ccf7..5b91e11b 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -14,3 +14,4 @@ {"run":13,"commit":"63a24da","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"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,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"测试代码质量 25→0:assert↔require 一致性(fail-fast)、float 精确比较→InDelta、Equal(\"\",x)→Empty、Equal(len)→Len、errors.Is/As→ErrorIs/ErrorAs、JSON 字符串→JSONEq、handler goroutine 内 require→assert(真健壮性修复)、t.Helper()、os.MkdirTemp→t.TempDir()(符合 repo AGENTS 约束)。","timestamp":1786875177918,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"修完测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2)","insight":"批量修复过程揭示 testifylint 默认 max-same-issues=3 会掩盖重复模式(len(entries) 出现 4+ 次、float64(3) 4 次),需 --max-issues-per-linter=0 反复收敛;全部修复语义中性(assert↔require 仅 fail-fast 差异,InDelta/JSONEq/Empty/Len/ErrorIs 等价断言,t.Helper/t.TempDir 纯改善)。go-require 类(handler 内 require→assert)是真正的健壮性修复","next_action_hint":"测试维度清零。生产代码剩余 8 全部刻意保留。可选深化:gocritic 更多子检查/staticcheck 更多(SA 系列)扫描、或 biome check 格式一次性提交、或前端 a11y(eslint jsx-a11y 已含于 next core-web-vitals 默认关闭项)"}} {"run":14,"commit":"65c02ef","metric":8,"metrics":{"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,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":36},"status":"keep","description":"基准扩展 exhaustive(文档化)+ 12→0:枚举 switch 补显式 case(全部与现有 default 行为等价,fail-explicit 防未来枚举静默落入 default);source_tasks.go 为控制复杂度合并两个等价校验条件。","timestamp":1786875548060,"segment":0,"confidence":4.25531914893617,"asi":{"hypothesis":"基准扩展 exhaustive(12 处枚举 switch 显式化)+ 全量修复","insight":"12 处全部是 default 已正确处理、缺显式 case 的类型;补显式 case 仅为 fail-explicit(未来枚举新增不会静默落入 default)。source_tasks 补 case 后 Execute 复杂度 20→21 触发 cyclop,合并两个 ActionInvalid 条件(逻辑等价)降回 19。cyclop 与 exhaustive 的张力:显式 case 也计入复杂度","next_action_hint":"剩余 8 全为刻意保留。可再深化:sloglint 全量、govet 附加分析器、或前端 jsx-a11y/next 规则已有覆盖。也可将剩余 8 处文档化后收尾总结"}} {"run":15,"commit":"d7b8f44","metric":8,"metrics":{"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":37},"status":"keep","description":"修复 geoip/runtime.go 真死代码:ensureServerMMDB 的 os.Stat 错误被 if-init 遮蔽,`err != nil && !os.IsNotExist(err)` 恒为 false(外层 err 恒 nil),防御检查从未生效;改为显式捕获 statErr,stat 非 not-exist 错误现在正确返回。基准新增第 4 维度 govet nilness+unusedwrite(文档化扩展),当前 0。","timestamp":1786875949461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"govet nilness 真实死代码 bug:ensureServerMMDB 的 stat 错误被 if-init 遮蔽,!os.IsNotExist(err) 恒为死条件(外层 err 恒 nil)","insight":"修复:显式捕获 statErr,使防御检查生效(stat 权限错误现在立即返回,不再静默吞掉后走 WriteFile 失败)。顺带基准扩展第 4 维度 govet nilness+unusedwrite(文档化,survey 过 fatcontext/containedctx/unparam/gocritic+29 检查:unparam 有 6+ 处真实死结果但需签名改动,留待下轮)","next_action_hint":"下轮候选:unparam(6+ 处 always-nil/never-used 结果,含 getSQLiteOverview/getPostgresOverview/getStatus 等,需改签名+调用方,churn 中等但都是真实死代码);或 fatcontext/containedctx(3+3 处,需逐处判断是否真反模式)"}} +{"run":16,"commit":"c85373f","metric":8,"metrics":{"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":43},"status":"keep","description":"unparam 死代码清理 12→2(保留 2 处 objectstore 构造函数统一签名):移除 10 处恒 nil error / 从未使用的结果(getPoWConfigForRoute 的恒 nil *PoWConfig、getSQLiteOverview/getPostgresOverview/getStatus/loadKumaConfig/filterExpectedRoutes 的恒 nil error、rawJSONString/parsePositiveInt 的弃用 bool、buildProxyRoute 的弃用 []ZoneDomain、getLocked 的恒 nil error),同步简化 12+ 处调用方与死错误检查。9 个受影响包测试通过。metric 持平 8(改进在基准之外)。","timestamp":1786876191447,"segment":0,"confidence":5.128205128205129,"asi":{"hypothesis":"unparam 死代码清理:10 处 always-nil error / never-used 结果从签名移除","insight":"移除后调用方同步简化(db_manage 的 err 检查、option routers 的 AbortBadRequestOnError 成为死代码一并删)。getPoWConfigForRoute 的 *PoWConfig 结果恒 nil 且从未被用 —— 真死代码。保留 2 处 objectstore 构造函数 (X, error):factory switch 统一签名(newS3Backend/newLocalBackend 等可能真实报错),unparam 在此为接口一致性误报。全部 9 个受影响包测试通过。metric 持平 8(改进在基准之外,诚实记录)","next_action_hint":"下一候选:fatcontext(3 处嵌套 context 闭包,多为 slog/otel ctx 传递,需逐处判断是否真反模式) 或 containedctx(3 处 struct 含 ctx 字段,含 webdav/uptimekuma client —— 重构风险中等);或收尾把 unparam 加入基准(2 处已知保留)"}} diff --git a/internal/apps/flared/frpc/manager.go b/internal/apps/flared/frpc/manager.go index c74f7bf9..2ead004c 100644 --- a/internal/apps/flared/frpc/manager.go +++ b/internal/apps/flared/frpc/manager.go @@ -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" diff --git a/internal/apps/flared/frpc/manager_test.go b/internal/apps/flared/frpc/manager_test.go index a189d3c8..971c5f46 100644 --- a/internal/apps/flared/frpc/manager_test.go +++ b/internal/apps/flared/frpc/manager_test.go @@ -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() diff --git a/internal/apps/openflare/tasks/ssl_renew_test.go b/internal/apps/openflare/tasks/ssl_renew_test.go index f79b8d7f..f298a269 100644 --- a/internal/apps/openflare/tasks/ssl_renew_test.go +++ b/internal/apps/openflare/tasks/ssl_renew_test.go @@ -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() } }