diff --git a/.auto/log.jsonl b/.auto/log.jsonl index a4d766c2..1a7b9265 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -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":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":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 中的可复现性"}} diff --git a/internal/apps/flared/frpc/manager_test.go b/internal/apps/flared/frpc/manager_test.go index 971c5f46..3e18c9d1 100644 --- a/internal/apps/flared/frpc/manager_test.go +++ b/internal/apps/flared/frpc/manager_test.go @@ -61,8 +61,12 @@ func assertStatusEventually(t *testing.T, m *Manager, relayID string, expectedSt for time.Now().Before(deadline) { m.mu.RLock() proc, ok := m.processes[relayID] + var status string + if ok { + status = proc.Status + } m.mu.RUnlock() - if ok && proc.Status == expectedStatus { + if ok && status == expectedStatus { return } time.Sleep(50 * time.Millisecond) diff --git a/internal/apps/oauth/cache.go b/internal/apps/oauth/cache.go index 72a9eafd..be1fe552 100644 --- a/internal/apps/oauth/cache.go +++ b/internal/apps/oauth/cache.go @@ -32,10 +32,12 @@ var ( tokenListenerOnce sync.Once tokenListenerCtx context.Context tokenListenerCancel context.CancelFunc + tokenListenerDone chan struct{} userListenerOnce sync.Once userListenerCtx context.Context userListenerCancel context.CancelFunc + userListenerDone chan struct{} ) func tokenCacheKey(tokenHash string) string { @@ -55,15 +57,19 @@ func ensureTokenCacheListener() { func startTokenCacheInvalidationListener() { tokenListenerCtx, tokenListenerCancel = context.WithCancel(context.Background()) + tokenListenerDone = make(chan struct{}) go func() { - pubsub := db.Redis.Subscribe(tokenListenerCtx, oauthTokenInvalidationChannel) + listenerCtx := tokenListenerCtx + defer close(tokenListenerDone) + + pubsub := db.Redis.Subscribe(listenerCtx, oauthTokenInvalidationChannel) defer func() { _ = pubsub.Close() }() go func() { - <-tokenListenerCtx.Done() + <-listenerCtx.Done() _ = pubsub.Close() }() @@ -94,15 +100,19 @@ func ensureUserCacheListener() { func startUserCacheInvalidationListener() { userListenerCtx, userListenerCancel = context.WithCancel(context.Background()) + userListenerDone = make(chan struct{}) go func() { - pubsub := db.Redis.Subscribe(userListenerCtx, oauthUserInvalidationChannel) + listenerCtx := userListenerCtx + defer close(userListenerDone) + + pubsub := db.Redis.Subscribe(listenerCtx, oauthUserInvalidationChannel) defer func() { _ = pubsub.Close() }() go func() { - <-userListenerCtx.Done() + <-listenerCtx.Done() _ = pubsub.Close() }() @@ -214,13 +224,21 @@ func InvalidateCachedUser(ctx context.Context, userID uint64) { func StopOauthCacheListener() { if tokenListenerCancel != nil { tokenListenerCancel() + if tokenListenerDone != nil { + <-tokenListenerDone + } tokenListenerCancel = nil + tokenListenerDone = nil } tokenListenerOnce = sync.Once{} if userListenerCancel != nil { userListenerCancel() + if userListenerDone != nil { + <-userListenerDone + } userListenerCancel = nil + userListenerDone = nil } userListenerOnce = sync.Once{} } diff --git a/internal/apps/oauth/oauth_test.go b/internal/apps/oauth/oauth_test.go index e5fec8c7..eca5d6fe 100644 --- a/internal/apps/oauth/oauth_test.go +++ b/internal/apps/oauth/oauth_test.go @@ -381,6 +381,11 @@ func resetOIDCProviderCacheForTest() { } 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() r := testhelper.NewTestGinEngine(gin.Recovery()) diff --git a/internal/apps/openflare/tls/acme_obtain_test.go b/internal/apps/openflare/tls/acme_obtain_test.go index 93d8c59a..5bee84a0 100644 --- a/internal/apps/openflare/tls/acme_obtain_test.go +++ b/internal/apps/openflare/tls/acme_obtain_test.go @@ -50,10 +50,19 @@ func TestApplyCertificateReturnsApplying(t *testing.T) { }) require.NoError(t, err) + obtainDone := make(chan struct{}) restore := SetObtainCertificateFuncForTest(func(ctx context.Context, cert *model.TLSCertificate) error { + defer close(obtainDone) 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{ Name: "Test ACME Cert", diff --git a/internal/apps/openflare/tls/logics.go b/internal/apps/openflare/tls/logics.go index 9fc5c7c2..60b427c0 100644 --- a/internal/apps/openflare/tls/logics.go +++ b/internal/apps/openflare/tls/logics.go @@ -197,12 +197,17 @@ func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertific return nil, err } + // 先取响应快照再启动异步续签:sanitize 会整体拷贝 cert,若与异步 goroutine + // 的字段写入并发会构成数据竞争(生产真实问题)。 + returned := sanitizeCertificateForResponse(cert) + + obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换) go func(c *model.TLSCertificate) { asyncCtx := context.WithoutCancel(ctx) - _ = obtainTLSCertificate(asyncCtx, c) + _ = obtainFn(asyncCtx, c) }(cert) - return sanitizeCertificateForResponse(cert), nil + return returned, nil } // UpdateACMECertificate 更新 ACME 证书配置。 @@ -225,12 +230,15 @@ func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*mod return nil, err } + returned := sanitizeCertificateForResponse(cert) + + obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换) go func(c *model.TLSCertificate) { asyncCtx := context.WithoutCancel(ctx) - _ = obtainTLSCertificate(asyncCtx, c) + _ = obtainFn(asyncCtx, c) }(cert) - return sanitizeCertificateForResponse(cert), nil + return returned, nil } // ConvertCertificateToACME 将上传证书转为 ACME 管理。 @@ -257,9 +265,10 @@ func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (* return nil, err } + obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换) go func(c *model.TLSCertificate) { asyncCtx := context.WithoutCancel(ctx) - if err := obtainTLSCertificate(asyncCtx, c); err != nil { + if err := obtainFn(asyncCtx, c); err != nil { return } latest, err := repository.GetTLSCertificateByID(asyncCtx, c.ID) diff --git a/internal/apps/relay/frps/manager_test.go b/internal/apps/relay/frps/manager_test.go index 19da7d21..62d3846e 100644 --- a/internal/apps/relay/frps/manager_test.go +++ b/internal/apps/relay/frps/manager_test.go @@ -7,7 +7,7 @@ import ( "os/exec" "path/filepath" "strings" - "sync/atomic" + "syscall" "testing" "time" @@ -307,17 +307,17 @@ func TestSupervisorGenerationInterrupt(t *testing.T) { t.Error("expected old process killed and new command started") } - // Verify old process is actually killed - var cmd1Finished int32 - go func() { - _ = cmd1.Wait() - atomic.StoreInt32(&cmd1Finished, 1) - }() - - time.Sleep(200 * time.Millisecond) - if atomic.LoadInt32(&cmd1Finished) != 1 { - t.Error("expected first process to be killed") + // Verify old process is actually killed:不要对受管 Cmd 调用 Wait(旧 supervise + // goroutine 拥有 Wait 权,并发 Wait 会与 os/exec 内部状态竞争),改为探测 + // 进程是否已被收割(Signal(0) 在 Wait 后即报错)。 + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := cmd1.Process.Signal(syscall.Signal(0)); err != nil { + return + } + time.Sleep(20 * time.Millisecond) } + t.Error("expected first process to be killed") } func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) { diff --git a/internal/apps/upload/cache/access_cache.go b/internal/apps/upload/cache/access_cache.go index a8526cb0..d513b1a5 100644 --- a/internal/apps/upload/cache/access_cache.go +++ b/internal/apps/upload/cache/access_cache.go @@ -52,12 +52,13 @@ func ensureAccessCacheListener() { } func startAccessCacheInvalidationListener() { - if db.Redis == nil { + redis := db.Redis // 调用方 goroutine 上捕获,避免 goroutine 内读可变全局(测试会替换 db.Redis) + if redis == nil { return } go func() { - pubsub := db.Redis.Subscribe( + pubsub := redis.Subscribe( context.Background(), objectstore.ConfigInvalidationChannel, fileAccessInvalidationChannel, diff --git a/internal/repository/auth_source_cache.go b/internal/repository/auth_source_cache.go index 2dd4aa7d..c075e83f 100644 --- a/internal/repository/auth_source_cache.go +++ b/internal/repository/auth_source_cache.go @@ -48,6 +48,7 @@ var ( authSourceListenerOnce sync.Once authSourceListenerCtx context.Context authSourceListenerCancel context.CancelFunc + authSourceListenerDone chan struct{} ) func cloneAuthSources(sources []model.AuthSource) []model.AuthSource { @@ -116,15 +117,19 @@ func ensureAuthSourceCacheListener() { func startAuthSourceCacheInvalidationListener() { authSourceListenerCtx, authSourceListenerCancel = context.WithCancel(context.Background()) + authSourceListenerDone = make(chan struct{}) go func() { - pubsub := db.Redis.Subscribe(authSourceListenerCtx, authSourceInvalidationChannel) + listenerCtx := authSourceListenerCtx + defer close(authSourceListenerDone) + + pubsub := db.Redis.Subscribe(listenerCtx, authSourceInvalidationChannel) defer func() { _ = pubsub.Close() }() go func() { - <-authSourceListenerCtx.Done() + <-listenerCtx.Done() _ = pubsub.Close() }() @@ -257,7 +262,11 @@ func InvalidateAuthSourceCache(ctx context.Context) error { func StopAuthSourceCacheListener() { if authSourceListenerCancel != nil { authSourceListenerCancel() + if authSourceListenerDone != nil { + <-authSourceListenerDone + } authSourceListenerCancel = nil + authSourceListenerDone = nil } authSourceListenerOnce = sync.Once{} } diff --git a/internal/repository/system_config_cache.go b/internal/repository/system_config_cache.go index 7a620461..956d2559 100644 --- a/internal/repository/system_config_cache.go +++ b/internal/repository/system_config_cache.go @@ -90,6 +90,7 @@ var ( systemConfigListenerOnce sync.Once systemConfigListenerCtx context.Context systemConfigListenerCancel context.CancelFunc + systemConfigListenerDone chan struct{} ) func ensureSystemConfigCacheListener() { @@ -102,15 +103,19 @@ func startSystemConfigCacheInvalidationListener() { } systemConfigListenerCtx, systemConfigListenerCancel = context.WithCancel(context.Background()) + systemConfigListenerDone = make(chan struct{}) go func() { - pubsub := db.Redis.Subscribe(systemConfigListenerCtx, SystemConfigBroadcastChannel) + listenerCtx := systemConfigListenerCtx + defer close(systemConfigListenerDone) + + pubsub := db.Redis.Subscribe(listenerCtx, SystemConfigBroadcastChannel) defer func() { _ = pubsub.Close() }() go func() { - <-systemConfigListenerCtx.Done() + <-listenerCtx.Done() _ = pubsub.Close() }() @@ -135,7 +140,11 @@ func startSystemConfigCacheInvalidationListener() { func StopSystemConfigCacheListener() { if systemConfigListenerCancel != nil { systemConfigListenerCancel() + if systemConfigListenerDone != nil { + <-systemConfigListenerDone + } systemConfigListenerCancel = nil + systemConfigListenerDone = nil } systemConfigListenerOnce = sync.Once{} }