全仓 go test -race 扫描(93 包)→ 全绿。修复 6 类数据竞争:frpc/frps 测试的锁外读与并发 Wait;oauth/repository 4 个 Pub/Sub 监听器 goroutine 读可变包变量(局部捕获 + done 通道等待);oauth 测试换 db.Redis 前停监听器;【真实生产 bug】tls 响应快照与异步续签 goroutine 并发写 cert 竞争(先快照再起 goroutine);upload/cache 监听器 goroutine 内读 db.Redis(调用方捕获)。

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,"vitest_failed":0,"vitest_total":116,"measure_s":74}
This commit is contained in:
ryan
2026-08-16 19:56:19 +08:00
parent 4962bf90d1
commit e1b439d6a5
10 changed files with 93 additions and 28 deletions
+1
View File
@@ -18,3 +18,4 @@
{"run":17,"commit":"a16be01","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":40},"status":"keep","description":"修复 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 升级为真实测试门禁。","timestamp":1786877266517,"segment":0,"confidence":7.142857142857143,"asi":{"hypothesis":"frpc 进程生命周期真 bug:exec.CommandContext 只杀直接子进程,孤儿孙进程持有 stderr 管道导致 cmd.Wait 阻塞到其自然退出(实测脚本 sleep 5 时 Stop 挂起 5s)","insight":"修复:Setpgid 独立进程组 + cmd.Cancel 覆盖为 Kill(-pid,SIGKILL) 整组击杀(经隔离复现 + 临时插桩定位,4 次假设检验收敛)。连带修复两个测试 bug:TestStopCancelsRunningProcesses 对 Manager 拥有的 Cmd 并发 Wait(与 os/exec ctxResult 通道竞争永久挂起)改为 Signal(0) 探测;ssl_renew 测试改用 miniredis(task 包 init() 创建真实 redis 客户端,违反 repo 无 init 装配约束)。成果:go test ./internal/... ./pkg/... 从 3 个失败→全绿(81+13 包),checks.sh 升级为真实测试门禁。metric 持平 8(改进在基准之外,但价值最高的一轮)","next_action_hint":"测试全绿后可解锁:paralleltest/tparallel 维度(t.Parallel 提速)——需先评估共享状态(miniredis/sqlite 每测试独立,风险低);或探索 relay/frps 同构代码是否有同样的 group-kill 问题(frps/manager 结构相同,值得检查)"}} {"run":17,"commit":"a16be01","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":40},"status":"keep","description":"修复 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 升级为真实测试门禁。","timestamp":1786877266517,"segment":0,"confidence":7.142857142857143,"asi":{"hypothesis":"frpc 进程生命周期真 bug:exec.CommandContext 只杀直接子进程,孤儿孙进程持有 stderr 管道导致 cmd.Wait 阻塞到其自然退出(实测脚本 sleep 5 时 Stop 挂起 5s)","insight":"修复:Setpgid 独立进程组 + cmd.Cancel 覆盖为 Kill(-pid,SIGKILL) 整组击杀(经隔离复现 + 临时插桩定位,4 次假设检验收敛)。连带修复两个测试 bug:TestStopCancelsRunningProcesses 对 Manager 拥有的 Cmd 并发 Wait(与 os/exec ctxResult 通道竞争永久挂起)改为 Signal(0) 探测;ssl_renew 测试改用 miniredis(task 包 init() 创建真实 redis 客户端,违反 repo 无 init 装配约束)。成果:go test ./internal/... ./pkg/... 从 3 个失败→全绿(81+13 包),checks.sh 升级为真实测试门禁。metric 持平 8(改进在基准之外,但价值最高的一轮)","next_action_hint":"测试全绿后可解锁:paralleltest/tparallel 维度(t.Parallel 提速)——需先评估共享状态(miniredis/sqlite 每测试独立,风险低);或探索 relay/frps 同构代码是否有同样的 group-kill 问题(frps/manager 结构相同,值得检查)"}}
{"run":18,"commit":"f5c9da0","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":39},"status":"keep","description":"前端测试套件 44 失败→全绿:10 个测试文件补 NextIntlClientProvider 包装(含 React19 createElement 类型修复、.ts→.tsx 重命名);修复真实 i18n ICU bug(githubUrlInvalid 的 {owner}/{repo} 未转义导致生产渲染成 key,zh/en + fragment 4 文件同步转义);更新 2 处过期测试期望。vitest 116/116 + tsc + eslint 全绿,checks.sh 增加前端测试门禁。","timestamp":1786878501539,"segment":0,"confidence":9.523809523809524,"asi":{"hypothesis":"前端测试可运行性:next-intl 迁移后 44/116 测试失败(缺 NextIntlClientProvider + 3 处真实断言问题)","insight":"修复三类:(1) 10 个测试文件的 render 助手缺 NextIntlClientProvider(createElement 与 JSX 混用踩 React19 类型坑,.ts 文件不能写 JSX → 重命名为 .tsx);(2) 真实 i18n bug:githubUrlInvalid 消息的 {owner}/{repo} 被 ICU 当占位符,t() 无参调用渲染成 key —— 需 '{' 单引号转义('{}' 内层转义不够,必须整体引号包裹 '{owner}'),4 个消息文件(zh/en + fragment 源)同步修复,check:i18n 通过;(3) 2 处测试期望过期(唯一访问者→查询窗口独立访客、检查间隔→检查间隔(分钟),以消息文件为准)。成果:116/116 vitest + tsc/eslint 全绿,checks.sh 增加前端测试门禁","next_action_hint":"前端测试全绿后可把 vitest 失败数纳入基准(当前不在基准内);或检查 app/(main) 目录下 3 个自带 .test.tsx(waf editor 系列)是否也符合新约定"}} {"run":18,"commit":"f5c9da0","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":39},"status":"keep","description":"前端测试套件 44 失败→全绿:10 个测试文件补 NextIntlClientProvider 包装(含 React19 createElement 类型修复、.ts→.tsx 重命名);修复真实 i18n ICU bug(githubUrlInvalid 的 {owner}/{repo} 未转义导致生产渲染成 key,zh/en + fragment 4 文件同步转义);更新 2 处过期测试期望。vitest 116/116 + tsc + eslint 全绿,checks.sh 增加前端测试门禁。","timestamp":1786878501539,"segment":0,"confidence":9.523809523809524,"asi":{"hypothesis":"前端测试可运行性:next-intl 迁移后 44/116 测试失败(缺 NextIntlClientProvider + 3 处真实断言问题)","insight":"修复三类:(1) 10 个测试文件的 render 助手缺 NextIntlClientProvider(createElement 与 JSX 混用踩 React19 类型坑,.ts 文件不能写 JSX → 重命名为 .tsx);(2) 真实 i18n bug:githubUrlInvalid 消息的 {owner}/{repo} 被 ICU 当占位符,t() 无参调用渲染成 key —— 需 '{' 单引号转义('{}' 内层转义不够,必须整体引号包裹 '{owner}'),4 个消息文件(zh/en + fragment 源)同步修复,check:i18n 通过;(3) 2 处测试期望过期(唯一访问者→查询窗口独立访客、检查间隔→检查间隔(分钟),以消息文件为准)。成果:116/116 vitest + tsc/eslint 全绿,checks.sh 增加前端测试门禁","next_action_hint":"前端测试全绿后可把 vitest 失败数纳入基准(当前不在基准内);或检查 app/(main) 目录下 3 个自带 .test.tsx(waf editor 系列)是否也符合新约定"}}
{"run":19,"commit":"c455be3","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,"vitest_failed":0,"vitest_total":116,"measure_s":62},"status":"keep","description":"基准扩展第 5 维度(文档化):前端 vitest 失败数纳入 total_issues(vitest_failed=0, total=116)。5 维全部处于下限,total=8 不变。","timestamp":1786878719509,"segment":0,"confidence":14.285714285714286,"asi":{"hypothesis":"基准扩展第 5 维度:前端 vitest 失败数(全绿后纳入防回归,文档化范围扩展非作弊)","insight":"measure_s 从 39s 升到 62s(vitest ~20s + eslint 冷启动),可接受。5 个维度全部在其下限:生产 8(全刻意保留)+ 测试 0 + govet 0 + eslint/tsc 0 + vitest 0","next_action_hint":"基准已 5 维全下限。后续可深化:paralleltest(现在测试可跑,但共享全局状态风险仍在,低优先);或 frontend biome 格式一次性提交(不进基准);或前端组件更深规则(jsx-a11y 已在 next core-web-vitals 覆盖)。也可认为会话到达稳定收尾点,更新 prompt/ideas 后总结"}} {"run":19,"commit":"c455be3","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,"vitest_failed":0,"vitest_total":116,"measure_s":62},"status":"keep","description":"基准扩展第 5 维度(文档化):前端 vitest 失败数纳入 total_issues(vitest_failed=0, total=116)。5 维全部处于下限,total=8 不变。","timestamp":1786878719509,"segment":0,"confidence":14.285714285714286,"asi":{"hypothesis":"基准扩展第 5 维度:前端 vitest 失败数(全绿后纳入防回归,文档化范围扩展非作弊)","insight":"measure_s 从 39s 升到 62s(vitest ~20s + eslint 冷启动),可接受。5 个维度全部在其下限:生产 8(全刻意保留)+ 测试 0 + govet 0 + eslint/tsc 0 + vitest 0","next_action_hint":"基准已 5 维全下限。后续可深化:paralleltest(现在测试可跑,但共享全局状态风险仍在,低优先);或 frontend biome 格式一次性提交(不进基准);或前端组件更深规则(jsx-a11y 已在 next core-web-vitals 覆盖)。也可认为会话到达稳定收尾点,更新 prompt/ideas 后总结"}}
{"run":20,"commit":"4962bf9","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,"vitest_failed":0,"vitest_total":116,"measure_s":86},"status":"keep","description":"两处真实质量修复:(1) 过期 swagger 文档重新生成(status_2xx/4xx/5xx_count 字段随 a4dd5ca9 加入后未同步 docs,违反 repo 约定,swag init 后差异仅真实新增字段);(2) generate-themes.js 输出补尾换行,themes.json 构建可复现(此前每次 build 弄脏工作树)。验证 next build 成功、musttag/tagalign 调查无真实问题。","timestamp":1786879144888,"segment":0,"confidence":25,"asi":{"hypothesis":"验证生产构建 + 修两处真实质量问题:swagger 文档过期(status_2xx/4xx/5xx_count 新增字段未重新生成)与 themes.json 构建不可复现(generate-themes.js 缺尾换行,每次 build 弄脏工作树)","insight":"next build 成功(无构建问题);musttag 3 处与 tagalign 均判定为非问题(持久化 round-trip 自洽/调试日志/纯格式)。swagger 差异仅 27 行且全部真实(a4dd5ca9 状态码拆分字段)。generate-themes.js 补 '\\n' 后 themes.json 再生与提交版完全一致,构建可复现。metric 持平 8(改进在基准之外)","next_action_hint":"会话已 5 维全下限 + 构建可复现 + 双端测试全绿。收尾候选:更新 prompt/ideas 记录本轮成果后总结;或继续验证 swag 生成的 docs.go 在 CI 中的可复现性"}}
+5 -1
View File
@@ -61,8 +61,12 @@ func assertStatusEventually(t *testing.T, m *Manager, relayID string, expectedSt
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
m.mu.RLock() m.mu.RLock()
proc, ok := m.processes[relayID] proc, ok := m.processes[relayID]
var status string
if ok {
status = proc.Status
}
m.mu.RUnlock() m.mu.RUnlock()
if ok && proc.Status == expectedStatus { if ok && status == expectedStatus {
return return
} }
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
+22 -4
View File
@@ -32,10 +32,12 @@ var (
tokenListenerOnce sync.Once tokenListenerOnce sync.Once
tokenListenerCtx context.Context tokenListenerCtx context.Context
tokenListenerCancel context.CancelFunc tokenListenerCancel context.CancelFunc
tokenListenerDone chan struct{}
userListenerOnce sync.Once userListenerOnce sync.Once
userListenerCtx context.Context userListenerCtx context.Context
userListenerCancel context.CancelFunc userListenerCancel context.CancelFunc
userListenerDone chan struct{}
) )
func tokenCacheKey(tokenHash string) string { func tokenCacheKey(tokenHash string) string {
@@ -55,15 +57,19 @@ func ensureTokenCacheListener() {
func startTokenCacheInvalidationListener() { func startTokenCacheInvalidationListener() {
tokenListenerCtx, tokenListenerCancel = context.WithCancel(context.Background()) tokenListenerCtx, tokenListenerCancel = context.WithCancel(context.Background())
tokenListenerDone = make(chan struct{})
go func() { go func() {
pubsub := db.Redis.Subscribe(tokenListenerCtx, oauthTokenInvalidationChannel) listenerCtx := tokenListenerCtx
defer close(tokenListenerDone)
pubsub := db.Redis.Subscribe(listenerCtx, oauthTokenInvalidationChannel)
defer func() { defer func() {
_ = pubsub.Close() _ = pubsub.Close()
}() }()
go func() { go func() {
<-tokenListenerCtx.Done() <-listenerCtx.Done()
_ = pubsub.Close() _ = pubsub.Close()
}() }()
@@ -94,15 +100,19 @@ func ensureUserCacheListener() {
func startUserCacheInvalidationListener() { func startUserCacheInvalidationListener() {
userListenerCtx, userListenerCancel = context.WithCancel(context.Background()) userListenerCtx, userListenerCancel = context.WithCancel(context.Background())
userListenerDone = make(chan struct{})
go func() { go func() {
pubsub := db.Redis.Subscribe(userListenerCtx, oauthUserInvalidationChannel) listenerCtx := userListenerCtx
defer close(userListenerDone)
pubsub := db.Redis.Subscribe(listenerCtx, oauthUserInvalidationChannel)
defer func() { defer func() {
_ = pubsub.Close() _ = pubsub.Close()
}() }()
go func() { go func() {
<-userListenerCtx.Done() <-listenerCtx.Done()
_ = pubsub.Close() _ = pubsub.Close()
}() }()
@@ -214,13 +224,21 @@ func InvalidateCachedUser(ctx context.Context, userID uint64) {
func StopOauthCacheListener() { func StopOauthCacheListener() {
if tokenListenerCancel != nil { if tokenListenerCancel != nil {
tokenListenerCancel() tokenListenerCancel()
if tokenListenerDone != nil {
<-tokenListenerDone
}
tokenListenerCancel = nil tokenListenerCancel = nil
tokenListenerDone = nil
} }
tokenListenerOnce = sync.Once{} tokenListenerOnce = sync.Once{}
if userListenerCancel != nil { if userListenerCancel != nil {
userListenerCancel() userListenerCancel()
if userListenerDone != nil {
<-userListenerDone
}
userListenerCancel = nil userListenerCancel = nil
userListenerDone = nil
} }
userListenerOnce = sync.Once{} userListenerOnce = sync.Once{}
} }
+5
View File
@@ -381,6 +381,11 @@ func resetOIDCProviderCacheForTest() {
} }
func setupTestRouter(dbConn *gorm.DB, mockRedis *mockRedisClient, mockClient *http.Client) *gin.Engine { func setupTestRouter(dbConn *gorm.DB, mockRedis *mockRedisClient, mockClient *http.Client) *gin.Engine {
// 停止各层 Pub/Sub 监听 goroutine(可能在先前测试的 API 调用中随 sync.Once
// 启动),否则它们在 db.Redis 被替换时仍读取旧值,产生数据竞争。
StopOauthCacheListener()
repository.StopAuthSourceCacheListener()
repository.StopSystemConfigCacheListener()
resetOIDCProviderCacheForTest() resetOIDCProviderCacheForTest()
r := testhelper.NewTestGinEngine(gin.Recovery()) r := testhelper.NewTestGinEngine(gin.Recovery())
@@ -50,10 +50,19 @@ func TestApplyCertificateReturnsApplying(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
obtainDone := make(chan struct{})
restore := SetObtainCertificateFuncForTest(func(ctx context.Context, cert *model.TLSCertificate) error { restore := SetObtainCertificateFuncForTest(func(ctx context.Context, cert *model.TLSCertificate) error {
defer close(obtainDone)
return updateCertError(ctx, cert, "dns challenge failed") return updateCertError(ctx, cert, "dns challenge failed")
}) })
defer restore() defer func() {
select {
case <-obtainDone:
case <-time.After(2 * time.Second):
t.Fatal("async certificate obtain did not finish")
}
restore()
}()
cert, err := ApplyCertificate(ctx, ApplyInput{ cert, err := ApplyCertificate(ctx, ApplyInput{
Name: "Test ACME Cert", Name: "Test ACME Cert",
+14 -5
View File
@@ -197,12 +197,17 @@ func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertific
return nil, err return nil, err
} }
// 先取响应快照再启动异步续签:sanitize 会整体拷贝 cert,若与异步 goroutine
// 的字段写入并发会构成数据竞争(生产真实问题)。
returned := sanitizeCertificateForResponse(cert)
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
go func(c *model.TLSCertificate) { go func(c *model.TLSCertificate) {
asyncCtx := context.WithoutCancel(ctx) asyncCtx := context.WithoutCancel(ctx)
_ = obtainTLSCertificate(asyncCtx, c) _ = obtainFn(asyncCtx, c)
}(cert) }(cert)
return sanitizeCertificateForResponse(cert), nil return returned, nil
} }
// UpdateACMECertificate 更新 ACME 证书配置。 // UpdateACMECertificate 更新 ACME 证书配置。
@@ -225,12 +230,15 @@ func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*mod
return nil, err return nil, err
} }
returned := sanitizeCertificateForResponse(cert)
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
go func(c *model.TLSCertificate) { go func(c *model.TLSCertificate) {
asyncCtx := context.WithoutCancel(ctx) asyncCtx := context.WithoutCancel(ctx)
_ = obtainTLSCertificate(asyncCtx, c) _ = obtainFn(asyncCtx, c)
}(cert) }(cert)
return sanitizeCertificateForResponse(cert), nil return returned, nil
} }
// ConvertCertificateToACME 将上传证书转为 ACME 管理。 // ConvertCertificateToACME 将上传证书转为 ACME 管理。
@@ -257,9 +265,10 @@ func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (*
return nil, err return nil, err
} }
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
go func(c *model.TLSCertificate) { go func(c *model.TLSCertificate) {
asyncCtx := context.WithoutCancel(ctx) asyncCtx := context.WithoutCancel(ctx)
if err := obtainTLSCertificate(asyncCtx, c); err != nil { if err := obtainFn(asyncCtx, c); err != nil {
return return
} }
latest, err := repository.GetTLSCertificateByID(asyncCtx, c.ID) latest, err := repository.GetTLSCertificateByID(asyncCtx, c.ID)
+11 -11
View File
@@ -7,7 +7,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic" "syscall"
"testing" "testing"
"time" "time"
@@ -307,17 +307,17 @@ func TestSupervisorGenerationInterrupt(t *testing.T) {
t.Error("expected old process killed and new command started") t.Error("expected old process killed and new command started")
} }
// Verify old process is actually killed // Verify old process is actually killed:不要对受管 Cmd 调用 Wait(旧 supervise
var cmd1Finished int32 // goroutine 拥有 Wait 权,并发 Wait 会与 os/exec 内部状态竞争),改为探测
go func() { // 进程是否已被收割(Signal(0) 在 Wait 后即报错)。
_ = cmd1.Wait() deadline := time.Now().Add(2 * time.Second)
atomic.StoreInt32(&cmd1Finished, 1) for time.Now().Before(deadline) {
}() if err := cmd1.Process.Signal(syscall.Signal(0)); err != nil {
return
time.Sleep(200 * time.Millisecond) }
if atomic.LoadInt32(&cmd1Finished) != 1 { time.Sleep(20 * time.Millisecond)
t.Error("expected first process to be killed")
} }
t.Error("expected first process to be killed")
} }
func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) { func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) {
+3 -2
View File
@@ -52,12 +52,13 @@ func ensureAccessCacheListener() {
} }
func startAccessCacheInvalidationListener() { func startAccessCacheInvalidationListener() {
if db.Redis == nil { redis := db.Redis // 调用方 goroutine 上捕获,避免 goroutine 内读可变全局(测试会替换 db.Redis)
if redis == nil {
return return
} }
go func() { go func() {
pubsub := db.Redis.Subscribe( pubsub := redis.Subscribe(
context.Background(), context.Background(),
objectstore.ConfigInvalidationChannel, objectstore.ConfigInvalidationChannel,
fileAccessInvalidationChannel, fileAccessInvalidationChannel,
+11 -2
View File
@@ -48,6 +48,7 @@ var (
authSourceListenerOnce sync.Once authSourceListenerOnce sync.Once
authSourceListenerCtx context.Context authSourceListenerCtx context.Context
authSourceListenerCancel context.CancelFunc authSourceListenerCancel context.CancelFunc
authSourceListenerDone chan struct{}
) )
func cloneAuthSources(sources []model.AuthSource) []model.AuthSource { func cloneAuthSources(sources []model.AuthSource) []model.AuthSource {
@@ -116,15 +117,19 @@ func ensureAuthSourceCacheListener() {
func startAuthSourceCacheInvalidationListener() { func startAuthSourceCacheInvalidationListener() {
authSourceListenerCtx, authSourceListenerCancel = context.WithCancel(context.Background()) authSourceListenerCtx, authSourceListenerCancel = context.WithCancel(context.Background())
authSourceListenerDone = make(chan struct{})
go func() { go func() {
pubsub := db.Redis.Subscribe(authSourceListenerCtx, authSourceInvalidationChannel) listenerCtx := authSourceListenerCtx
defer close(authSourceListenerDone)
pubsub := db.Redis.Subscribe(listenerCtx, authSourceInvalidationChannel)
defer func() { defer func() {
_ = pubsub.Close() _ = pubsub.Close()
}() }()
go func() { go func() {
<-authSourceListenerCtx.Done() <-listenerCtx.Done()
_ = pubsub.Close() _ = pubsub.Close()
}() }()
@@ -257,7 +262,11 @@ func InvalidateAuthSourceCache(ctx context.Context) error {
func StopAuthSourceCacheListener() { func StopAuthSourceCacheListener() {
if authSourceListenerCancel != nil { if authSourceListenerCancel != nil {
authSourceListenerCancel() authSourceListenerCancel()
if authSourceListenerDone != nil {
<-authSourceListenerDone
}
authSourceListenerCancel = nil authSourceListenerCancel = nil
authSourceListenerDone = nil
} }
authSourceListenerOnce = sync.Once{} authSourceListenerOnce = sync.Once{}
} }
+11 -2
View File
@@ -90,6 +90,7 @@ var (
systemConfigListenerOnce sync.Once systemConfigListenerOnce sync.Once
systemConfigListenerCtx context.Context systemConfigListenerCtx context.Context
systemConfigListenerCancel context.CancelFunc systemConfigListenerCancel context.CancelFunc
systemConfigListenerDone chan struct{}
) )
func ensureSystemConfigCacheListener() { func ensureSystemConfigCacheListener() {
@@ -102,15 +103,19 @@ func startSystemConfigCacheInvalidationListener() {
} }
systemConfigListenerCtx, systemConfigListenerCancel = context.WithCancel(context.Background()) systemConfigListenerCtx, systemConfigListenerCancel = context.WithCancel(context.Background())
systemConfigListenerDone = make(chan struct{})
go func() { go func() {
pubsub := db.Redis.Subscribe(systemConfigListenerCtx, SystemConfigBroadcastChannel) listenerCtx := systemConfigListenerCtx
defer close(systemConfigListenerDone)
pubsub := db.Redis.Subscribe(listenerCtx, SystemConfigBroadcastChannel)
defer func() { defer func() {
_ = pubsub.Close() _ = pubsub.Close()
}() }()
go func() { go func() {
<-systemConfigListenerCtx.Done() <-listenerCtx.Done()
_ = pubsub.Close() _ = pubsub.Close()
}() }()
@@ -135,7 +140,11 @@ func startSystemConfigCacheInvalidationListener() {
func StopSystemConfigCacheListener() { func StopSystemConfigCacheListener() {
if systemConfigListenerCancel != nil { if systemConfigListenerCancel != nil {
systemConfigListenerCancel() systemConfigListenerCancel()
if systemConfigListenerDone != nil {
<-systemConfigListenerDone
}
systemConfigListenerCancel = nil systemConfigListenerCancel = nil
systemConfigListenerDone = nil
} }
systemConfigListenerOnce = sync.Once{} systemConfigListenerOnce = sync.Once{}
} }