fix app shutdown config and guide gaps
This commit is contained in:
+3
-1
@@ -32,6 +32,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **`App.Init()` 由 `sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。
|
||||
- **`App.Go()` 在 Shutdown 开始或 Init 失败后为 no-op**(app.go,M1):`state >= stateStopping` 时拒绝 `wg.Add` 直接返回,避免与 `Shutdown` 的 `wg.Wait` 竞争 `sync.WaitGroup` 契约(Add 须 happen-before Wait)。依赖"Shutdown 后仍可 Go"的下游需改用独立 goroutine。
|
||||
- **生命周期 hook 不允许重入调用 `Init`/`Shutdown`/`Run`**(app.go,M1):`initMu` 非重入,hook(OnInit/OnStart/OnReady/OnStop)内调用会自锁死锁。需在 hook 内触发关闭应改用信号通道由主流程处理。
|
||||
- **`xlgo.WithConfig(cfg)` 改为快照语义并在 `Init` 时校验**(app.go,M1):传入配置会深拷贝到 App 私有快照,调用方后续修改原 `cfg` 不再影响 App;非法配置在 `Init` 返回中文校验错误。依赖“修改原 cfg 指针动态影响 App”的下游需改为重新创建 App 或使用配置管理器。
|
||||
- **`logger.DefaultLogger = m` 直接赋值不再驱动包级 facade**(logger/logger.go,M3):为消除 `SetDefaultLogManager()` 与包级 facade 并发读取默认 manager 的裸全局指针竞态,facade 改为读取内部 atomic 快照。`logger.DefaultLogger` 仍保持 `*LogManager` 类型,旧的 `logger.DefaultLogger.Init/Close/SetLevel/GetLevel` 直接调用仍可用;替换默认 manager 请使用 `logger.SetDefaultLogManager(m)`。
|
||||
- **`logger.Init` 拒绝明显非法日志配置**(logger/logger.go,M3):空日志目录、负数 `MaxSize/MaxBackups/MaxAge` 现在直接返回错误。依赖零值日志配置启动 `WithLogger()` 的下游需显式设置 `Log.Dir` 与非负轮转参数。
|
||||
- **限流器非法配置改为 fail-fast**(middleware/ratelimit.go,M8):`NewRateLimiter` / `NewRedisRateLimiter` / `NewRedisRateLimiterFailClosed` 现在对 `rate <= 0` 或 `window <= 0` 直接 panic,避免零值窗口/零值配额静默产生不确定限流语义。下游应在配置加载阶段校验限流参数。
|
||||
@@ -50,6 +51,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
|
||||
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。
|
||||
- **M16 测试工具、脚手架与示例闭环修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码;`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式;README/GUIDE 限流示例不再引用不存在的 `handler.Login` / `handler.Upload`。
|
||||
- **M16 GUIDE/test API 不一致修复**:GUIDE 测试示例不再调用不存在的 `AssertCode` / `AssertJSONKeyExists`,统一改用现有 `AssertJSONContains`,避免照文档编写测试直接编译失败。
|
||||
- **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。
|
||||
|
||||
- **M13 cron handler panic 未 recover 崩进程**(cron/cron.go):`RunTask` 与 `checkAndRun` 调度 goroutine 统一经新增 `executeTask(t)` 边界 `recover`,panic 转为 error(含 `debug.Stack` 调用栈)记入 `task.LastError` 并向上返回,不再终止进程。外侧 `defer wg.Done()`/`running` 守卫释放不受影响(recover 在边界内完成)。顺带修复 `RunTask` 手动路径此前只更 `LastRun/RunCount`、不记 `LastError` 的子问题(现与调度路径一致)。
|
||||
@@ -61,7 +63,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **Init 失败无资源回滚** → 新增 `failAfterInit`:markStopping → cancel rootCtx → wg.Wait(10s) → cron 5s 显式超时停止 → `closeResources` 幂等关闭 db/redis/logger,回滚错误 `errors.Join` 进 `initErr` 不吞;先停 goroutine 再关资源,避免"关 DB 时探活 goroutine 仍在用"的竞态。
|
||||
- **App.Go 与 wg.Wait race** → `lifecycleMu.RLock` 包住 `wg.Add`,`Shutdown` 持写锁翻 `stateStopping`,保证 Add happens-before Wait。
|
||||
- **Shutdown 非幂等/非并发安全** → `shutdownOnce` 保证 `doShutdown` 单次执行,并发调用者返回同一 `shutdownErr`。
|
||||
- **OnStop 语义** → 仅 `Init` 曾成功(`wasInitialized`)时在 `doShutdown` 开头执行;Init 失败/未 Init 时 HTTP 从未启动,OnStop 跳过。
|
||||
- **OnStop 语义与超时** → 仅 `Init` 曾成功(`wasInitialized`)时在 `doShutdown` 开头执行;Init 失败/未 Init 时 HTTP 从未启动,OnStop 跳过。OnStop 现在受 `server.shutdown_timeout` 同一预算约束,阻塞 hook 不再无限拖住 Shutdown。
|
||||
- **资源所有权** → App 只关闭自己成功初始化过的 logger/db/redis/cron,避免一个 Init 失败的新 App 关闭同进程既有全局资源。
|
||||
- **复审补强:资源替换事务边界** → App 初始化 logger/db/redis 时先创建 App-owned manager 并保存旧默认 manager 快照;OnInit 或后续步骤失败时恢复旧默认 manager 并关闭新资源,完整成功后才释放旧资源,避免“新 App Init 失败”破坏同进程既有全局 logger/db/redis。
|
||||
- **复审补强:health/probing 绑定 App-owned manager** → App 注册的 MySQL/Redis health check 与 DB probing 使用本 App 持有的 manager,不随后续全局默认 manager 替换漂移。
|
||||
|
||||
@@ -1666,12 +1666,12 @@ func TestUserAPI(t *testing.T) {
|
||||
WithJSON(map[string]any{"name": "test"}).
|
||||
Execute()
|
||||
resp.AssertOK(t)
|
||||
resp.AssertCode(t, 1)
|
||||
resp.AssertJSONContains(t, "code", float64(1))
|
||||
|
||||
// GET请求
|
||||
resp = test.GET(router, "/api/users/1").Execute()
|
||||
resp.AssertOK(t)
|
||||
resp.AssertJSONKeyExists(t, "data.id")
|
||||
resp.AssertJSONContains(t, "data.id", float64(1))
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ type Migrator func(*gorm.DB) error
|
||||
// - OnInit: Init() 内组件初始化完成后、路由注册前
|
||||
// - OnStart: StartServer() 监听端口前
|
||||
// - OnReady: 端口就绪后(已开始接受连接)
|
||||
// - OnStop: Shutdown() 开头,关 HTTP 之前
|
||||
// - OnStop: Shutdown() 开头,关 HTTP 之前,受 server.shutdown_timeout 约束
|
||||
//
|
||||
// OnInit/OnStart/OnReady/OnStop 返回 error 会中断流程并向上返回。
|
||||
// A2 修复:OnReady 改为返回 error,失败时触发 shutdown。
|
||||
@@ -152,11 +152,12 @@ func WithConfigPath(path string) Option {
|
||||
}
|
||||
|
||||
// WithConfig 设置配置对象。
|
||||
// A3 修复:不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。
|
||||
// 配置会在传入时深拷贝成 App 私有快照,并在 Init 时 Validate;调用方后续修改 cfg
|
||||
// 不会污染 App 内部配置。不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。
|
||||
// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath。
|
||||
func WithConfig(cfg *config.Config) Option {
|
||||
return func(a *App) {
|
||||
a.config = cfg
|
||||
a.config = cfg.Clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +606,25 @@ func (a *App) shutdownAfterStartFailure(startErr error) error {
|
||||
return startErr
|
||||
}
|
||||
|
||||
func (a *App) runOnStopHook(ctx context.Context, h Hook) error {
|
||||
if h.OnStop == nil {
|
||||
return nil
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- h.OnStop(a)
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return fmt.Errorf("OnStop hook %q 失败: %w", h.Name, err)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("OnStop hook %q 超时: %w", h.Name, ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// doInit 执行实际初始化流程。仅在 Init 持有 initMu 并完成状态切换后调用,不可直接调用。
|
||||
func (a *App) doInit() error {
|
||||
cfg, err := a.resolveConfig()
|
||||
@@ -759,7 +779,12 @@ func (a *App) doInit() error {
|
||||
|
||||
func (a *App) resolveConfig() (*config.Config, error) {
|
||||
if a.config != nil {
|
||||
return a.config, nil
|
||||
cfg := a.config.Clone()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("配置校验失败: %w", err)
|
||||
}
|
||||
a.config = cfg
|
||||
return cfg, nil
|
||||
}
|
||||
if a.configManager == nil && a.configPath != "" {
|
||||
a.configManager = config.NewManager(a.configPath)
|
||||
@@ -941,6 +966,7 @@ func (a *App) Shutdown() error {
|
||||
// 顺序(M1 调整):OnStop(仅 Init 曾成功时)→ cancel rootCtx → server.Shutdown →
|
||||
// wg.Wait → cron / 限流器 / db / redis / logger。OnStop 在 shutdown 开头、关 HTTP 之前,
|
||||
// 保有 cancel 前协调资源的机会;state=Stopping 已阻新 Go,无需先 cancel/wait 再跑 hook。
|
||||
// OnStop 也受同一个 shutdownTimeout 预算约束,避免 hook 卡死拖住整个进程退出。
|
||||
func (a *App) doShutdown(wasInitialized bool) error {
|
||||
shutdownTimeout := 30 * time.Second
|
||||
if a.config != nil {
|
||||
@@ -955,9 +981,10 @@ func (a *App) doShutdown(wasInitialized bool) error {
|
||||
// Init 失败/未 Init 时 HTTP 从未启动,OnStop 语义不适用。
|
||||
if wasInitialized {
|
||||
for _, h := range a.hooks {
|
||||
if h.OnStop != nil {
|
||||
if err := h.OnStop(a); err != nil {
|
||||
errs = append(errs, fmt.Errorf("OnStop hook %q 失败: %w", h.Name, err))
|
||||
if err := a.runOnStopHook(ctx, h); err != nil {
|
||||
errs = append(errs, err)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,54 @@ func TestAppShutdownIdempotent_M1(t *testing.T) {
|
||||
// TestAppInitFailureRollsBackCron_M1(Edge 3 + 资源回滚):Init 失败(OnInit hook 报错)时,
|
||||
// 已 cron.Start() 的全局调度器必须被 failAfterInit 停止,canary 任务不再触发。
|
||||
// 修复前:Init 失败不停 cron → canary 在下个 1s tick 跑 → ran > 0。
|
||||
// TestAppShutdownTimeoutCoversOnStop_M1 回归:OnStop 必须受 server.shutdown_timeout 约束。
|
||||
// 修复前:OnStop 同步执行且不看超时,阻塞 hook 会拖死 Shutdown。
|
||||
func TestAppShutdownTimeoutCoversOnStop_M1(t *testing.T) {
|
||||
cfg := testConfig(18105)
|
||||
cfg.Server.ShutdownTimeout = 30 * time.Millisecond
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(cfg),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "slow-stop", OnStop: func(*xlgo.App) error {
|
||||
<-release
|
||||
return nil
|
||||
}}),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := app.Shutdown()
|
||||
if err == nil || !strings.Contains(err.Error(), "OnStop hook") {
|
||||
t.Fatalf("Shutdown err = %v, want OnStop timeout error", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("Shutdown took %s, OnStop timeout did not bound shutdown", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithConfigClonesAndValidates_M1 回归:WithConfig 捕获私有快照,并在 Init 时校验。
|
||||
func TestWithConfigClonesAndValidates_M1(t *testing.T) {
|
||||
cfg := testConfig(18106)
|
||||
app := xlgo.New(xlgo.WithConfig(cfg))
|
||||
cfg.Server.Port = -1 // 不应污染 App 内部快照
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init with cloned config failed after caller mutation: %v", err)
|
||||
}
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
|
||||
bad := testConfig(-1)
|
||||
err := xlgo.New(xlgo.WithConfig(bad)).Init()
|
||||
if err == nil || !strings.Contains(err.Error(), "配置校验失败") {
|
||||
t.Fatalf("Init with invalid WithConfig err = %v, want validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppInitFailureRollsBackCron_M1(t *testing.T) {
|
||||
cron.StopGlobalWithTimeout(time.Second) // 清前序残留
|
||||
t.Cleanup(func() {
|
||||
|
||||
Reference in New Issue
Block a user