package xlgo_test import ( "context" "errors" "net" "os" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "testing" "time" xlgo "github.com/EthanCodeCraft/xlgo-core" "github.com/EthanCodeCraft/xlgo-core/config" "github.com/EthanCodeCraft/xlgo-core/cron" "github.com/EthanCodeCraft/xlgo-core/logger" "gorm.io/gorm" ) // TestAppGoAddWaitRace_M1 回归(须 -race):App.Go 的 wg.Add 与 Shutdown 的 wg.Wait 并发, // 必须由 lifecycleMu 串行化(Add happens-before Wait),否则违反 WaitGroup 契约。 // 修复前:-race 报 wg.Add/wg.Wait data race 或 "negative WaitGroup counter" panic。 func TestAppGoAddWaitRace_M1(t *testing.T) { const M = 20 const iters = 20 for iter := 0; iter < iters; iter++ { app := xlgo.New() var spawned, exited atomic.Int32 gate := make(chan struct{}) var goDone sync.WaitGroup goDone.Add(M) for i := 0; i < M; i++ { go func() { <-gate app.Go(func(ctx context.Context) { spawned.Add(1) <-ctx.Done() exited.Add(1) }) goDone.Done() }() } shutCh := make(chan error, 1) go func() { <-gate shutCh <- app.Shutdown() }() close(gate) goDone.Wait() err := <-shutCh if err != nil { t.Fatalf("iter %d: Shutdown error: %v", iter, err) } sp, ex := spawned.Load(), exited.Load() if sp != ex { t.Fatalf("iter %d: spawned %d != exited %d (goroutine leak / Add-after-Wait)", iter, sp, ex) } } } // TestAppGoRefusedAfterShutdown_M1:Shutdown 后 Go() 必须拒绝(state>=Stopping),不 spawn。 func TestAppGoRefusedAfterShutdown_M1(t *testing.T) { app := xlgo.New(xlgo.WithConfig(testConfig(18102))) if err := app.Shutdown(); err != nil { t.Fatalf("Shutdown: %v", err) } ran := make(chan struct{}, 1) app.Go(func(context.Context) { ran <- struct{}{} }) time.Sleep(50 * time.Millisecond) // 给可能被 spawn 的 goroutine 时间发送 select { case <-ran: t.Fatal("Go ran after Shutdown — should be refused (state=Stopping)") default: } } // TestAppInitAfterShutdownRejected_M1(Edge 1):Shutdown 后再 Init 必须返 ErrAppClosed。 func TestAppInitAfterShutdownRejected_M1(t *testing.T) { app := xlgo.New(xlgo.WithConfig(testConfig(18101))) if err := app.Shutdown(); err != nil { t.Fatalf("Shutdown: %v", err) } if err := app.Init(); !errors.Is(err, xlgo.ErrAppClosed) { t.Fatalf("Init after Shutdown err = %v, want ErrAppClosed", err) } } // TestAppShutdownIdempotent_M1:并发 Shutdown 幂等,OnStop 仅执行一次。 func TestAppShutdownIdempotent_M1(t *testing.T) { var stopCount atomic.Int32 app := xlgo.New( xlgo.WithConfig(testConfig(18100)), xlgo.WithHook(xlgo.Hook{Name: "count-stop", OnStop: func(*xlgo.App) error { stopCount.Add(1); return nil }}), ) if err := app.Init(); err != nil { t.Fatalf("Init: %v", err) } var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done(); _ = app.Shutdown() }() go func() { defer wg.Done(); _ = app.Shutdown() }() wg.Wait() if stopCount.Load() != 1 { t.Fatalf("OnStop called %d times, want 1 (idempotent)", stopCount.Load()) } if err := app.Shutdown(); err != nil { t.Fatalf("third Shutdown error: %v", err) } if stopCount.Load() != 1 { t.Fatalf("OnStop called %d times after 3rd, want 1", stopCount.Load()) } } // TestAppInitFailureRollsBackCron_M1(Edge 3 + 资源回滚):Init 失败(OnInit hook 报错)时, // 已 cron.Start() 的全局调度器必须被 failAfterInit 停止,canary 任务不再触发。 // 修复前:Init 失败不停 cron → canary 在下个 1s tick 跑 → ran > 0。 func TestAppInitFailureRollsBackCron_M1(t *testing.T) { cron.StopGlobalWithTimeout(time.Second) // 清前序残留 t.Cleanup(func() { cron.GetScheduler().RemoveTask("canary-m1") cron.StopGlobalWithTimeout(time.Second) }) var ran atomic.Int32 cron.AddTask("canary-m1", cron.Every(time.Millisecond), func(context.Context) error { ran.Add(1) return nil }) app := xlgo.New( xlgo.WithConfig(testConfig(18104)), xlgo.WithCron(), xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom-init") }}), ) err := app.Init() if err == nil || !strings.Contains(err.Error(), "boom-init") { t.Fatalf("Init err = %v, want boom-init", err) } time.Sleep(1300 * time.Millisecond) // 跨一个 1s ticker if ran.Load() != 0 { t.Fatalf("canary ran %d times after Init failure — cron not stopped by rollback", ran.Load()) } } // TestAppGoRefusedAfterInitFailure_M1:Init 失败后 failAfterInit 置 state=Stopping, // 后续 Go() 拒绝;先前的 Go goroutine 因 rootCtx cancel 退出(不泄漏)。 func TestAppGoRefusedAfterInitFailure_M1(t *testing.T) { app := xlgo.New( xlgo.WithConfig(testConfig(18103)), xlgo.WithMigrator(func(*gorm.DB) error { return nil }), // 无 WithMySQL → Init 确定性失败 ) firstDone := make(chan struct{}) app.Go(func(ctx context.Context) { <-ctx.Done(); close(firstDone) }) if err := app.Init(); err == nil { t.Fatal("expected Init error") } select { case <-firstDone: case <-time.After(5 * time.Second): t.Fatal("first Go goroutine did not exit after Init failure (rootCtx not cancelled)") } ran := make(chan struct{}, 1) app.Go(func(context.Context) { ran <- struct{}{} }) time.Sleep(50 * time.Millisecond) select { case <-ran: t.Fatal("Go ran after Init failure — should be refused (state=Stopping)") default: } } // TestAppInitFailureDoesNotCloseUnownedGlobals_M1 回归:一个尚未成功初始化任何资源的 // App Init 失败时,不得关闭进程里已有的全局 logger/db/redis 等资源。 func TestAppInitFailureDoesNotCloseUnownedGlobals_M1(t *testing.T) { dir := t.TempDir() if err := logger.Init(&config.Config{ App: config.AppConfig{Env: "test"}, Server: config.ServerConfig{Mode: "development"}, Log: config.LogConfig{Dir: dir, MaxSize: 1, MaxBackups: 1, MaxAge: 1}, }); err != nil { t.Fatalf("logger.Init: %v", err) } t.Cleanup(func() { _ = logger.Close() }) missingConfig := filepath.Join(t.TempDir(), "missing.yaml") if err := xlgo.New(xlgo.WithConfigPath(missingConfig)).Init(); err == nil { t.Fatal("expected Init error without config") } const mark = "M1_UNOWNED_LOGGER_STILL_ACTIVE" logger.Info(mark) _ = logger.Sync() data, err := os.ReadFile(filepath.Join(dir, "app.log")) if err != nil { t.Fatalf("read app.log: %v", err) } if !strings.Contains(string(data), mark) { t.Fatal("unowned global logger was closed by failed App.Init") } } // TestAppInitShutdownConcurrentSerialization_M1 直接覆盖 initMu 串行化 doInit/doShutdown // (Edge 2):Init 持 initMu 期间(OnInit hook 阻塞),并发 Shutdown 必须阻塞等 Init 释放, // 不能与 doInit 并发改资源。确定性逻辑测试,不依赖 -race 概率。 func TestAppInitFailureRestoresReplacedLogger_M1(t *testing.T) { oldMgr := logger.NewLogManager() oldDir := t.TempDir() if err := oldMgr.Init(&config.Config{ App: config.AppConfig{Env: "test"}, Server: config.ServerConfig{Mode: "development"}, Log: config.LogConfig{Dir: oldDir, MaxSize: 1, MaxBackups: 1, MaxAge: 1}, }); err != nil { t.Fatalf("old logger Init: %v", err) } logger.SetDefaultLogManager(oldMgr) t.Cleanup(func() { _ = logger.Close() logger.SetDefaultLogManager(logger.NewLogManager()) }) cfg := testConfig(18105) cfg.Log.Dir = t.TempDir() app := xlgo.New( xlgo.WithConfig(cfg), xlgo.WithLogger(), xlgo.WithHook(xlgo.Hook{Name: "fail-after-logger", OnInit: func(*xlgo.App) error { return errors.New("boom-after-logger") }}), ) err := app.Init() if err == nil || !strings.Contains(err.Error(), "boom-after-logger") { t.Fatalf("Init err = %v, want boom-after-logger", err) } if got := logger.GetDefaultLogManager(); got != oldMgr { t.Fatalf("default logger manager was not restored after Init failure: got %p want %p", got, oldMgr) } const mark = "M1_RESTORED_LOGGER_STILL_ACTIVE" logger.Info(mark) _ = logger.Sync() data, err := os.ReadFile(filepath.Join(oldDir, "app.log")) if err != nil { t.Fatalf("read old app.log: %v", err) } if !strings.Contains(string(data), mark) { t.Fatal("restored logger did not write to old app.log") } } func TestAppInitShutdownConcurrentSerialization_M1(t *testing.T) { initStarted := make(chan struct{}) releaseInit := make(chan struct{}) app := xlgo.New( xlgo.WithConfig(testConfig(18200)), xlgo.WithHook(xlgo.Hook{ Name: "block-init", OnInit: func(*xlgo.App) error { close(initStarted) <-releaseInit return nil }, }), ) initDone := make(chan error, 1) go func() { initDone <- app.Init() }() <-initStarted // OnInit 在 doInit 末尾运行 → 此时 Init 持 initMu // 并发 Shutdown:必须阻塞等 initMu,不能在 Init 完成前返回。 shutDone := make(chan error, 1) go func() { shutDone <- app.Shutdown() }() select { case <-shutDone: t.Fatal("Shutdown returned before Init finished — initMu not serializing doInit/doShutdown") case <-time.After(150 * time.Millisecond): // 期望:Shutdown 仍阻塞等 initMu } close(releaseInit) // 让 OnInit 返回 → doInit 完成 → Init 释放 initMu select { case err := <-initDone: if err != nil { t.Fatalf("Init: %v", err) } case <-time.After(5 * time.Second): t.Fatal("Init did not return after release") } select { case <-shutDone: // Shutdown 在 Init 后获得 initMu 并完成 case <-time.After(5 * time.Second): t.Fatal("Shutdown did not return after Init finished — initMu not released?") } } // TestAppStartServerListenFailureSkipsOnReady_M1 回归:监听失败时不得先执行 OnReady。 // 修复前 StartServer 在 goroutine 内 ListenAndServe,主 goroutine 立即跑 OnReady; // 端口被占用时仍可能先触发 ready 副作用,再返回启动失败。 func TestAppStartServerListenFailureSkipsOnReady_M1(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("occupy port: %v", err) } defer ln.Close() port := ln.Addr().(*net.TCPAddr).Port cfg := testConfig(port) cfg.Server.Host = "127.0.0.1" var ready atomic.Int32 app := xlgo.New( xlgo.WithConfig(cfg), xlgo.WithHook(xlgo.Hook{ Name: "ready-must-not-run", OnReady: func(*xlgo.App) error { ready.Add(1) return nil }, }), ) if err := app.Init(); err != nil { t.Fatalf("Init: %v", err) } err = app.StartServer() if err == nil || !strings.Contains(err.Error(), "服务器监听失败") { t.Fatalf("StartServer err = %v, want listen failure", err) } if ready.Load() != 0 { t.Fatalf("OnReady ran %d times despite listen failure", ready.Load()) } _ = app.Shutdown() } // TestAppStartServerTLSFailureSkipsOnReady_M1 回归:TLS 证书装配失败也不得触发 OnReady。 func TestAppStartServerTLSFailureSkipsOnReady_M1(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("reserve port: %v", err) } port := ln.Addr().(*net.TCPAddr).Port if err := ln.Close(); err != nil { t.Fatalf("release port: %v", err) } cfg := testConfig(port) cfg.Server.Host = "127.0.0.1" cfg.Server.TLS.Enabled = true cfg.Server.TLS.CertFile = filepath.Join(t.TempDir(), "missing.crt") cfg.Server.TLS.KeyFile = filepath.Join(t.TempDir(), "missing.key") var ready atomic.Int32 app := xlgo.New( xlgo.WithConfig(cfg), xlgo.WithHook(xlgo.Hook{ Name: "tls-ready-must-not-run", OnReady: func(*xlgo.App) error { ready.Add(1) return nil }, }), ) if err := app.Init(); err != nil { t.Fatalf("Init: %v", err) } err = app.StartServer() if err == nil || !strings.Contains(err.Error(), "服务器 TLS 配置无效") { t.Fatalf("StartServer err = %v, want TLS configuration failure", err) } if ready.Load() != 0 { t.Fatalf("OnReady ran %d times despite TLS failure", ready.Load()) } _ = app.Shutdown() } // TestAppStartServerUnixSocketListens_M1 回归:server.unix_socket 必须真的通过 // net.Listen("unix", path) 监听,而不是把 path 塞给 TCP ListenAndServe 的 Addr。 func TestAppStartServerUnixSocketListens_M1(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows unix socket support varies by environment") } sock := filepath.Join(t.TempDir(), "xlgo.sock") cfg := testConfig(0) cfg.Server.UnixSocket = sock app := xlgo.New( xlgo.WithConfig(cfg), xlgo.WithHook(xlgo.Hook{ Name: "dial-unix-ready", OnReady: func(*xlgo.App) error { conn, err := net.DialTimeout("unix", sock, time.Second) if err != nil { return err } _ = conn.Close() return errors.New("stop after unix socket ready") }, }), ) if err := app.Init(); err != nil { t.Fatalf("Init: %v", err) } err := app.StartServer() if err == nil || !strings.Contains(err.Error(), "stop after unix socket ready") { t.Fatalf("StartServer err = %v, want OnReady sentinel after unix dial", err) } }