Compare commits

...

4 Commits

80 changed files with 3900 additions and 176 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## v0.27.0-preview(2026-09-29)
- HTTP 配置新增 defaultMaxBodyBytes、maxBodyBytes、shutdownCancelTimeout;三种内置服务统一默认/路由/硬请求体限额,配置冲突在监听前报告完整路由与限额。native/HTTP2 不再以隐含 8 MiB 阻挡已放宽的 App 上传路由。
- App 在所有服务入口跟踪完整请求处理链;关闭连接后有界等待处理器实际退出。超时仍有请求则返回 ErrResourcesRetained,跳过 App 清理;Runtime.Finish 继续停止后台工作但保留依赖,生成入口保留日志。
- native 提前拒绝上传后有界半关闭连接,减少 TCP reset 吞掉错误响应;业务 HTTP 200 策略不再触发对被拒绝请求体的继续读取。
- HTTP/2 Shutdown 同时等待连接和独立处理器,修复客户端断开后误判完成。增加真实 TLS/流式上传、取消收尾、拒绝晚到请求、资源保留和生成消费者回归,以及 httpSafety 示例。
- 迁移:nethttp 手写普通路由新增默认 8 MiB 请求体上限,大请求显式覆盖;生成契约默认 1 MiB 保持。HTTP 停机多一个默认 5s 取消收尾窗口;外部宿主须将运行错误传给 Runtime.Finish,并遵守资源保留信号。
## v0.26.0-preview(2026-09-29)
- 新增独立 i18n:不可变命名空间词典、源/默认/启用语言、BCP 47 匹配、显式回退、参数/条件/基数复数和有界资源加载;不依赖 HTTP、业务或数据库。

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.26.0-preview)
# 通用业务开发指南(v0.27.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。

81
PROGRAM.md Normal file
View File

@@ -0,0 +1,81 @@
# 通用 Go 项目模板
从 v0.28.0-preview 起,`project new/generate/check` 提供不绑定协议与业务的工程骨架。默认只有配置、日志、依赖装配、生命周期和普通 Go 运行入口;不生成 `.api`、HTTP Handler、Swagger、业务任务,也不监听端口、连接数据库或队列。不增加第三方依赖。
## 创建与运行
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
go-omni project new demo --module example.com/demo
cd demo
go mod tidy
go run . -check-config
go run . # 默认等待 SIGINT / SIGTERM
go build -o program .
```
目标目录必须为空;未指定 module 时使用目录名。远程安装的 CLI 将自己的固定版本写入 go.mod,无本地替换。当前通用模板要求框架 v0.28.0-preview 或更新版本。源码开发可显式 `--framework-dir /path/to/go-omni`,不会从当前工作目录隐式选择本地框架。`--framework-version` 可指定兼容版本。框架版本、依赖更新由项目修改 go.mod,重新生成不会代为升级依赖。
常驻程序、一次性程序用同一份骨架:项目自己的 `Run` 决定何时完成。完整生成项目见 [examples/program](examples/program/README.md),不需要外部服务。
## 文件职责
| 路径 | 维护方与职责 |
| --- | --- |
| `main.go` | 生成器:信号、启动、运行入口监督、退出状态与日志收尾 |
| `internal/config/loadGen.go` | 生成器:统一配置加载 |
| `internal/svc/runtimeGen.go` | 生成器:依赖初始化及失败收尾 |
| `internal/config/config.go` | 项目:Config、Default、Validate;初始只含 log/lifecycle |
| `internal/config/loader.go` | 项目:按需注册额外文件格式 |
| `etc/config.yaml` | 项目:配置与显式环境引用 |
| `internal/svc/serviceContext.go` | 项目:依赖字段、Init、资源与后台工作登记 |
| `internal/run/run.go` | 项目:Run 的实际实现 |
| `go.mod`、README、.gitignore、其他 Go 文件 | 项目:生成器不覆盖 |
| `.goOmni/projectManifest.json` | 生成器:工程身份、模板版本、生成文件哈希;应提交 Git |
配置遵守 [CONFIG.md](CONFIG.md):默认值 → 文件显式 `${NAME}` / `${NAME:-fallback}` → 类型绑定 → 校验,没有隐式环境覆盖。JSON/YAML/XML 内置;`-config ""` 只用默认值。`-check-config` 不打开日志文件、不初始化依赖、不调用 Run。日志通过独立实例传给 svc,并注入 Context/WorkContext;不替换进程级默认日志实例。
## 运行与退出
```go
func Run(ctx context.Context, deps *svc.ServiceContext) error {
// 一次性程序可完成处理后直接 return nil。
// 常驻程序收到 ctx.Done() 后停止自己的主流程。
<-ctx.Done()
return nil
}
```
`nil` 表示正常完成,返回 error 表示运行失败。后台组件用 `RequestShutdown(err)` 报告致命故障,`RequestShutdown(nil)` 请求正常停止。框架不替项目重试、重新调度或重启任务。
依赖在 `svc.Init` 中装配:创建后立即用 `OnClose` 登记清理,后台组件同时登记同名 `OnStop` 和 `OnDrain`。所有登记应在 Init 返回前完成;Run 不得并发追加生命周期登记。组件何时接收工作由项目决定,停止后不得重新开始领取或启动未登记的工作。
- `Context()` 是提前停止通知,用来停止主流程或接收新工作。
- `WorkContext()` 在正常排空期间仍有效,用于已接受的工作。
- `OnStop` 停止接收新工作,不关闭处理中的工作仍依赖的资源。
- `OnDrain` 返回 nil,必须意味着组件处理、确认和 defer 收尾均已结束。仅返回 `ctx.Err()` 不能证明结束。
- 全部停止钩子先于全部排空钩子;同阶段并行。有先后依赖的组件在一个钩子内显式排序。
- 全部工作确认结束后,资源按 `OnClose` 登记的反序释放,最后关闭入口拥有的日志。
生成入口自动监督 Run 本身,以 `go-omni/project/run` 保留名称登记完成屏障。收到信号、Run 返回或报告故障后开始收尾,同时等待 Run 及其他已登记工作。Run 不应等待资源关闭:资源关闭发生在 Run 完成之后。
初始化失败同样收尾已创建的资源和已启动的工作。Init/Run 内 panic 被转为失败,安全收尾后进程以非零状态退出;任意自建 goroutine 的 panic 仍由项目负责处理。Run 不响应取消或其他工作无法确认结束时,`lifecycle.stopTimeout/drainTimeout/cancelTimeout` 限定等待窗口,返回 `ErrResourcesRetained`,跳过共享资源和日志关闭,以失败状态退出。取消后的工作不能被 Go 强制终止,保留资源不代表工作已经完成。SIGKILL 和进程崩溃不会执行退出钩子。
详见 [生命周期契约](lifecycle/README.md)。现成组件必须按其真实停止/完成语义适配,不能把某个 `Run/Close` 返回直接视为业务结束。
## 生成检查与兼容
```sh
go-omni project generate --dry-run
go-omni project generate
go-omni project check # 等价于 project generate --check
go test ./...
```
生成器不执行项目 Go 代码、不下载依赖。check/预览只读,不创建锁文件;生成时使用独立项目锁、路径和哈希预检。生成文件被手工修改会报冲突,不提供强制覆盖。删除的生成文件可补回;缺失的项目所有文件必须显式恢复,避免把删除的业务文件当成空白骨架重建。
每个文件通过原子替换写入,普通写入失败回滚此前变更;整个目录不具备崩溃事务性。生成期间不要同时手工编辑。发现遗留 project.lock 时,先确认没有生成进程再手工移除。
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。现有 `workspace` 命令仍面向 API 契约工程;多个通用工程可分别执行 `project check --dir <目录>`,本版不引入混合工作区配置。

View File

@@ -1,6 +1,8 @@
# 生成项目与文件维护边界
# API 生成项目与文件维护边界
v0.26.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
通用 Go 工程使用独立的 `project new/generate/check`,见 [PROGRAM.md](PROGRAM.md)。本文说明 API 契约工程。
v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发
@@ -98,7 +100,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后取消请求并关闭连接,再按 `http.shutdownCancelTimeout` 等待处理函数收尾。仍未结束时保留依赖并返回 `ErrResourcesRetained`,不会提前关闭数据库。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
## 依赖主动申请退出
@@ -109,7 +111,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
c.RequestShutdown(err)
```
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再等待 HTTP 取消收尾窗口。通过 `svc.Finish(runErr)` 执行后台停机;确认工作结束才关闭依赖与日志,无法确认时保留资源并非零退出。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
@@ -215,7 +217,7 @@ get /download (DownloadReq)
## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.26.0-preview:
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.28.0-preview:
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。

View File

@@ -1,10 +1,14 @@
# GoOmni
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.26.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。
**v0.28 改动**:新增独立的 `project new/generate/check`,默认无 API、监听端口、业务任务或外部服务配置;保护手写代码,并等待主运行入口和登记的后台工作结束后释放依赖。见 [通用工程指南](PROGRAM.md)和[可运行示例](examples/program/README.md)。
**从这份 README 开始接入。** 当前版本 **`v0.26.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.28.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。
**从这份 README 开始接入。** 当前版本 **`v0.28.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
@@ -36,6 +40,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| --- | --- | --- |
| 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) |
| 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) |
| 通用 Go 工程 | `go-omni project new/generate/check`;配置、日志、依赖、运行与退出 | [通用工程指南](PROGRAM.md)、[完整可运行示例](examples/program/README.md) |
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
| HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) |
@@ -64,6 +69,22 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
## 通用工程快速开始
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
go-omni project new demo --module example.com/demo
cd demo
go mod tidy
go run . -check-config
go run .
# Ctrl+C / SIGTERM 退出;项目自己的 Run 也可以完成后直接返回。
go-omni project check
go test ./...
```
项目只修改配置、svc 依赖初始化和 Run;默认没有 HTTP 端口、数据库、队列或自动重试。`main.go`、加载和装配代码由生成器维护;手改受管文件会报冲突,手写文件保持。完整维护边界、异常退出和超时行为见 [PROGRAM.md](PROGRAM.md)。
## 浏览器跨域 CORS
同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入:
@@ -156,7 +177,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
```sh
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
@@ -313,7 +334,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.28.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -589,6 +610,9 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
| `server.http.readHeaderTimeout/readTimeout` | 请求头 / 请求读取超时 |
| `server.http.writeTimeout/idleTimeout` | 响应写入 / Keep-Alive 空闲超时 |
| `server.http.maxHeaderBytes` | 请求头大小限制,字节数 |
| `server.http.defaultMaxBodyBytes` | 普通 App 路由默认请求体上限,零默认 8 MiB;路由可覆盖 |
| `server.http.maxBodyBytes` | 服务请求体硬上限,路由不得超过;零不添加低于框架 1 TiB 的硬上限 |
| `server.http.shutdownCancelTimeout` | 请求取消后等待处理函数收尾,零默认 5s;未结束则保留资源 |
| `server.shutdownTimeout` | 优雅退出等待时间,默认 10s |
| `server.enableLogger` / `server.log.level` | 访问日志开关、日志级别 |
| `server.trustedProxies` | 可信代理 IP/CIDR,文件数组 |
@@ -616,13 +640,15 @@ server:
maxHeaderBytes: 1048576
```
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**正值覆盖、零值保留所选网络层的原有默认行为、负值报错**;不配置时升级行为不变。超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**网络 I/O 参数正值覆盖、零值保留所选网络层的原有默认行为、负值报错**。请求体与取消收尾字段按上表解释;网络超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
外部宿主在注册完成后可用 `app.ValidateHTTP()` 预检配置与路由限额。上传路由使用 `app.HandleWithBodyLimit("POST", "/upload", 16<<20, nil, handler)` 或 `.api @request(maxBytes: 16777216)`;限额涵盖整个请求体。配置硬上限时至少允许该值;multipart 单文件、字段数以及幂等中间件缓存限制仍独立配置。新增普通路由默认 8 MiB;原 nethttp 手写大请求请显式设置默认值或路由上限,生成路由既有 `maxBytes`(默认 1 MiB)保持。
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。先停止接收新请求并等待排空,超时取消请求、关闭连接,再等待 `http.shutdownCancelTimeout` 收尾窗口。只有处理函数和它的 defer 全部退出才清理资源;仍有请求时返回 `lifecycle.ErrResourcesRetained`,跳过 App 清理钩子。生成入口通过 `deps.Finish(runErr)` 同步保留数据库等依赖及日志,非零退出。`App.Close`、`Engine`、`NewServer`、`RunWith` 共用这个完成屏障;业务自行启动的 goroutine、Hijack 后的工作需另行注册后台生命周期。
## 日常开发与升级
@@ -639,7 +665,7 @@ go test ./...
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
CLI 与业务 module 固定使用 `v0.26.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
CLI 与业务 module 固定使用 `v0.28.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入

View File

@@ -1,3 +1,46 @@
# v0.28.0-preview
新增通用 Go 工程生成命令 `project new/generate/check`。默认只有配置、日志、svc 依赖装配、生命周期与项目维护的 Run 入口,不生成 API、HTTP Handler、Swagger、业务任务或网络监听,也不自动连接数据库/队列或重试任务。常驻和一次性程序由普通 Go 代码决定。
入口监督 Run 的真实完成并复用现有 Runtime:信号、正常结束、运行错误或初始化失败都会进入收尾;先停止接收工作、等待已登记工作及 Run 结束,再反序释放依赖并关闭日志。Run/Init panic 转为失败;超时仍有工作则传播 ErrResourcesRetained,保留共享资源及日志,以非零状态退出。组件登记在 svc.Init 完成,业务语义和调度方式由项目负责。
配置遵守现有显式文件引用约定;支持 -check-config,在初始化日志和依赖前校验。生成器只管理 main/loadGen/runtimeGen,手写文件保留,生成文件被修改时拒绝覆盖;check/预览只读,独立 manifest/锁和共享文件事务保障生成。新模板要求框架 v0.28.0-preview;已有 new/api/workspace 行为保留,无强制迁移和新增第三方依赖。
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
go-omni project new demo --module example.com/demo
cd demo
go mod tidy
go run . -check-config
go run .
go-omni project check
```
完整 [通用工程指南](PROGRAM.md)、[可运行示例](examples/program/README.md)、[回归记录](reports/programAcceptance.md)。56 个测试包全量 race 与 Go 1.22 回归、生成消费者、vet/build 和真实进程退出验证通过。
---
# v0.27.0-preview
新增统一请求体策略与请求完成屏障,无业务字段、数据库依赖或新第三方包。`server.http.defaultMaxBodyBytes` 控制普通路由默认值(零为 8 MiB),`maxBodyBytes` 为不可由路由突破的服务硬上限(零仅保留 1 TiB 框架上界)。已有显式路由和生成契约限额保持,冲突会在启动前报错;旧 nethttp 手写大请求需显式设置路由或默认上限。multipart 单文件、中间件缓存及代理限额仍各自配置。
HTTP 关闭连接后继续等待处理器实际退出,收尾窗口由 `server.http.shutdownCancelTimeout` 设置(零默认 5s)。超时仍有处理器运行时返回 `lifecycle.ErrResourcesRetained`,App/Runtime/生成入口保留共享依赖和日志;不再将连接关闭当成业务完成。`Runtime.Finish(runErr)` 会传播该信号,手写入口必须处理它,避免无条件关闭额外资源。该行为适用于全部 App 服务入口;HTTP/2 低层 Shutdown 同时等待独立处理器。
详见 [请求体策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)、[运行示例](examples/httpSafety/README.md)。CLI 与框架固定使用同版 v0.27.0-preview;模板最低兼容版本仍为 v0.22,HTTP 安全改进需要升级运行时。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.27.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview
go-omni api generate --check
go test ./...
```
54 个测试包全量 race、Go 1.22 兼容检查、生成项目集成测试、vet/build 及真实进程关闭检查通过,见 [验收记录](reports/httpSafetyAcceptance.md)。
空模块缓存安装远程固定版本后,10 组生成消费者和 98 项发布检查通过,耗时 107.57 秒,无本地 replace;覆盖上传限额、取消收尾、HTTP/2 断连、生成项目依赖与日志保留。标签固定在 `3b00bb55d189cbe480c7d15c8d14bcd09d3f2d52`,见 [远程验收记录](reports/httpSafetyRemote.json)。
---
# v0.26.0-preview
新增可独立使用的 i18n 底层模块及可选 HTTP/错误适配。项目维护文案和语言设置,框架提供不可变词典、语言匹配、回退、参数与分支、检查和导出;没有绑定业务身份、页面框架、数据库或响应字段名。
@@ -17,6 +60,8 @@ go-omni api generate --check
go test ./...
```
发布前 54 个测试包全量 race 与 Go 1.22(含生成消费者)、vet/build、真实进程检查通过。空缓存安装远程固定版本后,10 组生成消费者和 87 项发布检查通过,耗时 83.76 秒,无本地 replace;包含下载模块的国际化、CLDR/浏览器一致性、TypeScript 类型和 CLI 工具链验证。标签固定在 `d1a747c36f15d964dd445d661623b68a45892adf`,[远程验收记录](reports/i18nRemote.json)。
---
# v0.25.0-preview

View File

@@ -33,6 +33,10 @@ type App struct {
closeErr error
clientIPResolver *httpx.ClientIPResolver
responseEncoder response.Encoder
requests requestDrain
bodyLimits []routeBodyLimit
validateOnce sync.Once
validateErr error
}
// Runner is the small lifecycle boundary used by an HTTP server
@@ -100,7 +104,14 @@ func newApp(router httpx.Router, settings config.Config) *App {
}
middlewares = append(middlewares, middleware.Recovery())
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
application.handler = corsPolicy.Wrap(router)
application.requests.done = make(chan struct{})
application.handler = corsPolicy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > application.hardBodyLimit() {
application.adapt(func(*httpx.Context) error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }).ServeHTTP(w, r)
return
}
router.ServeHTTP(w, r)
}))
if fallback, ok := router.(httpx.FallbackRouter); ok {
adaptError := func(status int, code, message string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -113,15 +124,31 @@ func newApp(router httpx.Router, settings config.Config) *App {
}
// Router exposes the underlying routing contract for advanced registration.
// Serve requests through App or Engine so application CORS policy is applied.
// Serve through App or Engine for CORS, hard body limits and shutdown tracking.
func (a *App) Router() httpx.Router { return a.router }
// Engine is kept as a compatibility name for the old API. It returns a
// standard http.Handler, so callers do not depend on a router type.
func (a *App) Engine() http.Handler { return a.handler }
func (a *App) Engine() http.Handler { return a }
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
ctx, work := a.requests.begin(request.Context())
if work == nil {
// Do not invoke encoders, middleware or loggers after their owners close.
http.Error(writer, "server shutting down", http.StatusServiceUnavailable)
return
}
defer a.requests.end(work)
a.validateOnce.Do(func() { a.validateErr = a.ValidateHTTP() })
if a.validateErr != nil {
http.Error(writer, "invalid HTTP configuration", http.StatusServiceUnavailable)
return
}
request = request.WithContext(ctx)
if request.Body != nil {
request.Body = http.MaxBytesReader(writer, request.Body, a.hardBodyLimit())
}
a.handler.ServeHTTP(writer, request)
}
@@ -143,7 +170,8 @@ func (a *App) Use(middleware ...httpx.Middleware) *App {
}
// OnShutdown registers a cleanup function owned by the application. Cleanup
// functions run in reverse registration order when Close or RunContext ends.
// functions run in reverse registration order after all handlers have returned.
// Incomplete draining skips these callbacks and returns ErrResourcesRetained.
// This keeps omni.New as the only public startup entry point while allowing
// projects to attach databases, loggers and other resources to its lifecycle.
func (a *App) OnShutdown(cleanup func() error) *App {
@@ -153,9 +181,21 @@ func (a *App) OnShutdown(cleanup func() error) *App {
return a
}
// Close releases application resources. It is safe to call more than once.
// Close stops admission, cancels active requests and waits for their handlers
// to return before releasing resources. It is idempotent. If cancellation grace
// expires, ErrResourcesRetained is permanent and no cleanup callbacks run.
// Use RunContext/ServeContext for graceful draining before cancellation. Do not
// call Close from a handler or cleanup callback; it would wait for itself.
func (a *App) Close() error {
a.closeOnce.Do(func() {
timeout := a.config.HTTP.ShutdownCancelTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
if err := a.requests.cancelAndWait(timeout); err != nil {
a.closeErr = err
return
}
for index := len(a.closers) - 1; index >= 0; index-- {
if err := a.closers[index](); err != nil {
a.closeErr = errors.Join(a.closeErr, err)
@@ -177,7 +217,7 @@ func (a *App) Run(address ...string) error {
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
// Use RunContext or ServeContext to apply the configured transport and TLS.
func (a *App) NewServer(address string) *http.Server {
if err := a.config.HTTP.Validate(); err != nil {
if err := a.ValidateHTTP(); err != nil {
panic(err)
}
if address == "" {
@@ -187,7 +227,7 @@ func (a *App) NewServer(address string) *http.Server {
}
}
cfg := a.config.HTTP
return &http.Server{Addr: address, Handler: a.handler,
return &http.Server{Addr: address, Handler: a,
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
@@ -202,6 +242,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) (runErr error)
if ctx.Err() != nil {
return a.Close()
}
stopWatching := a.watchShutdown(ctx)
defer stopWatching()
serve, err := a.prepareTransport()
if err != nil {
return errors.Join(err, a.Close())
@@ -228,6 +270,11 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (ru
if ctx.Err() != nil {
return a.Close()
}
if err := a.ValidateHTTP(); err != nil {
return errors.Join(err, a.Close())
}
stopWatching := a.watchShutdown(ctx)
defer stopWatching()
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
}
@@ -261,12 +308,16 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
select {
case err := <-serveErr:
// Serve returning only proves the accept loop ended. Cancel any remaining
// connections before the application completion barrier and cleanup.
_ = server.Close()
closeErr := a.Close()
if err == http.ErrServerClosed {
return closeErr
}
return errors.Join(err, closeErr)
case <-ctx.Done():
a.requests.stop()
shutdownTimeout := a.config.ShutdownTimeout
if shutdownTimeout <= 0 {
shutdownTimeout = 10 * time.Second
@@ -275,8 +326,8 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
defer cancel()
shutdownErr := server.Shutdown(shutdownCtx)
if shutdownErr != nil {
// Shutdown leaves active connections open on timeout. Close them so
// request contexts are canceled before releasing shared resources.
// Closing sockets requests cancellation; App.Close separately waits
// for the actual handler return before releasing shared resources.
shutdownErr = errors.Join(shutdownErr, server.Close())
}
if err := <-serveErr; err != nil && err != http.ErrServerClosed {

View File

@@ -1,11 +1,68 @@
package app
import (
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
"fmt"
"net/http"
"strings"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
"git.yuntuo.hk/k2safe/go-omni/response"
)
type routeBodyLimit struct {
method, path string
maxBytes int64
}
func (a *App) hardBodyLimit() int64 {
if a.config.HTTP.MaxBodyBytes > 0 {
return a.config.HTTP.MaxBodyBytes
}
return httplimits.MaxBodyBytes
}
func (a *App) defaultBodyLimit() int64 {
limit := a.config.HTTP.DefaultMaxBodyBytes
if limit <= 0 {
limit = httplimits.DefaultBodyBytes
}
if limit > a.hardBodyLimit() {
limit = a.hardBodyLimit()
}
// Invalid configuration is rejected at startup or the first external request.
if limit > httplimits.MaxBodyBytes {
limit = httplimits.MaxBodyBytes
}
return limit
}
// ValidateHTTP checks HTTP settings and registered route body limits without
// opening a listener. Call after registration when hosting App/Engine externally.
// Built-in serving entry points and RunWith perform this check automatically.
func (a *App) ValidateHTTP() error {
if err := a.config.HTTP.Validate(); err != nil {
return err
}
for _, route := range a.bodyLimits {
if route.maxBytes > a.hardBodyLimit() {
return &config.ConfigError{Field: "http.maxBodyBytes", Message: fmt.Sprintf("route %s %s maxBytes=%d exceeds service hard limit=%d; raise http.maxBodyBytes or lower the route limit", route.method, route.path, route.maxBytes, a.hardBodyLimit())}
}
}
return nil
}
func joinRoutePath(prefix, path string) string {
if path == "" {
if prefix == "" {
return "/"
}
return prefix
}
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
}
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
checked := httpx.LimitBody(maxBytes)(handler)
if encoder == nil {
@@ -25,8 +82,10 @@ func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler http
// the response policy, including auth failures and parameter errors.
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
a.bodyLimits = append(a.bodyLimits, routeBodyLimit{method, path, maxBytes})
}
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
g.app.bodyLimits = append(g.app.bodyLimits, routeBodyLimit{method, joinRoutePath(g.prefix, path), maxBytes})
}

224
app/bodyPolicy_test.go Normal file
View File

@@ -0,0 +1,224 @@
package app
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func serveBodyPolicy(t *testing.T, a *App, transport string) (string, *http.Client) {
t.Helper()
cert, key, clientTLS := testutil.CertificateFiles(t)
a.config.Transport = transport
a.config.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- a.ServeContext(ctx, l) }()
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: clientTLS, ForceAttemptHTTP2: transport == config.TransportHTTP2}}
t.Cleanup(func() {
client.CloseIdleConnections()
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(5 * time.Second):
t.Error("server did not stop")
}
})
return "https://" + l.Addr().String(), client
}
func consumeBody(c *httpx.Context) error {
_, err := io.Copy(io.Discard, c.Request().Body)
if err != nil {
return err
}
return c.Text(200, "accepted")
}
func TestUploadPolicyAcrossTransports(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
a := New()
a.POST("/default", consumeBody)
a.HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
base, client := serveBodyPolicy(t, a, transport)
payload := bytes.Repeat([]byte{'x'}, 9<<20)
for _, chunked := range []bool{false, true} {
for _, path := range []string{"/default", "/large", "/v1/files/large"} {
var body io.Reader = bytes.NewReader(payload)
if chunked {
body = struct{ io.Reader }{body}
}
req, _ := http.NewRequest("POST", base+path, body)
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Fatal(err)
}
want := 200
if path == "/default" {
want = 413
}
if res.StatusCode != want || (want == 413 && !strings.Contains(string(data), "BODY_TOO_LARGE")) {
t.Fatalf("%s unknownLength=%v: %d %s", path, chunked, res.StatusCode, data)
}
if transport == config.TransportHTTP2 && res.ProtoMajor != 2 {
t.Fatal("HTTP/2 not negotiated")
}
}
}
})
}
}
func TestConfiguredBodyPolicyBeforeMiddleware(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 32, MaxBodyBytes: 128}))
var calls atomic.Int32
a.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
data, err := io.ReadAll(c.Request().Body)
if err != nil {
return err
}
c.Request().Body = io.NopCloser(bytes.NewReader(data))
return next(c)
}
})
h := func(c *httpx.Context) error { calls.Add(1); return consumeBody(c) }
a.POST("/default", h)
a.HandleWithBodyLimit("POST", "/override", 64, nil, h)
a.HandleWithBodyLimit("POST", "/hard", 128, nil, h)
base, client := serveBodyPolicy(t, a, transport)
for _, tc := range []struct {
path string
size, status int
}{
{"/default", 32, 200}, {"/default", 33, 413}, {"/override", 64, 200}, {"/override", 65, 413}, {"/hard", 128, 200}, {"/hard", 129, 413},
} {
for _, unknown := range []bool{false, true} {
before := calls.Load()
var body io.Reader = strings.NewReader(strings.Repeat("a", tc.size))
if unknown {
body = struct{ io.Reader }{body}
}
res, err := client.Post(base+tc.path, "application/octet-stream", body)
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil || res.StatusCode != tc.status {
t.Fatalf("%+v unknown=%v: %d %s %v", tc, unknown, res.StatusCode, data, err)
}
if tc.status == 413 && (calls.Load() != before || !strings.Contains(string(data), "BODY_TOO_LARGE")) {
t.Fatal("oversize reached business or lost error", string(data))
}
}
}
})
}
}
func TestBodyHardLimitConflictBeforeServing(t *testing.T) {
for _, entry := range []string{"RunContext", "ServeContext", "RunWith", "NewServer", "Engine"} {
t.Run(entry, func(t *testing.T) {
a := New(config.WithHTTPConfig(config.HTTPConfig{MaxBodyBytes: 8 << 20}))
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/upload/", 16<<20, nil, consumeBody)
closed := 0
a.OnShutdown(func() error { closed++; return nil })
if err := a.ValidateHTTP(); err == nil {
t.Fatal("external preflight accepted conflicting route limit")
}
var err error
switch entry {
case "RunContext":
err = a.RunContext(context.Background(), "bad address")
case "ServeContext":
l, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
err = a.ServeContext(context.Background(), l)
if _, e = l.Accept(); !errors.Is(e, net.ErrClosed) {
t.Fatal("listener leaked", e)
}
case "RunWith":
err = a.RunWith(context.Background(), runnerFunc(func(context.Context, http.Handler, string) error { t.Error("runner started"); return nil }))
case "NewServer":
func() {
defer func() {
if p := recover(); p != nil {
err, _ = p.(error)
}
}()
a.NewServer("")
}()
_ = a.Close()
case "Engine":
w := httptest.NewRecorder()
a.Engine().ServeHTTP(w, httptest.NewRequest("POST", "/v1/files/upload/", nil))
if w.Code != 503 {
t.Fatal(w.Code)
}
err = a.validateErr
_ = a.Close()
}
for _, part := range []string{"POST /v1/files/upload/", "16777216", "8388608", "http.maxBodyBytes"} {
if err == nil || !strings.Contains(err.Error(), part) {
t.Fatalf("missing %q: %v", part, err)
}
}
if closed != 1 {
t.Fatal("cleanup count", closed)
}
})
}
}
func TestDefaultBodyLimitRegistrationPaths(t *testing.T) {
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 4, MaxBodyBytes: 16}))
h := func(*httpx.Context) error { t.Error("oversize reached business"); return nil }
encoder, _ := response.New(response.Config{})
a.Handle("POST", "/handle", h)
a.HandleWithResponse("POST", "/response", encoder, h)
a.Any("/any", h)
g := a.Group("/group")
g.POST("/post", h)
g.Any("/any", h)
g.HandleWithResponse("POST", "/response", encoder, h)
for _, path := range []string{"/handle", "/response", "/any", "/group/post", "/group/any", "/group/response"} {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", path, strings.NewReader("12345"))
a.ServeHTTP(w, r)
if w.Code != 413 {
t.Fatal(path, w.Code, w.Body.String())
}
}
}

88
app/requests.go Normal file
View File

@@ -0,0 +1,88 @@
package app
import (
"context"
"errors"
"fmt"
"sync"
"time"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
// Admission and completion share a lock: once stopped, no request can enter
// between observing zero active handlers and releasing their dependencies.
type requestDrain struct {
mu sync.Mutex
stopped bool
active map[*requestWork]struct{}
done chan struct{}
}
type requestWork struct{ cancel context.CancelFunc }
func (d *requestDrain) begin(ctx context.Context) (context.Context, *requestWork) {
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return ctx, nil
}
ctx, cancel := context.WithCancel(ctx)
w := &requestWork{cancel: cancel}
if d.active == nil {
d.active = make(map[*requestWork]struct{})
}
d.active[w] = struct{}{}
return ctx, w
}
func (d *requestDrain) end(w *requestWork) {
w.cancel()
d.mu.Lock()
defer d.mu.Unlock()
delete(d.active, w)
if d.stopped && len(d.active) == 0 {
close(d.done)
}
}
func (d *requestDrain) stop() {
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
d.stopped = true
if len(d.active) == 0 {
close(d.done)
}
}
func (d *requestDrain) cancelAndWait(timeout time.Duration) error {
d.stop()
d.mu.Lock()
for w := range d.active {
w.cancel()
}
d.mu.Unlock()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-d.done:
return nil
case <-timer.C:
select {
case <-d.done:
return nil
default:
}
return fmt.Errorf("go-omni: HTTP handlers did not finish within cancellation grace %s: %w", timeout,
errors.Join(context.DeadlineExceeded, lifecycle.ErrResourcesRetained))
}
}
// watchShutdown stops admission on the early signal without canceling accepted
// work. The transport retains its graceful drain budget before Close cancels.
func (a *App) watchShutdown(ctx context.Context) func() bool {
return context.AfterFunc(ctx, a.requests.stop)
}

167
app/requests_test.go Normal file
View File

@@ -0,0 +1,167 @@
package app
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func TestShutdownWaitsForCancellationCleanup(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
cert, key, tlsClient := testutil.CertificateFiles(t)
a := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(30*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: time.Second}))
started, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{})
var once sync.Once
unblock := func() { once.Do(func() { close(release) }) }
defer unblock()
var closed atomic.Int32
a.OnShutdown(func() error { closed.Add(1); return nil })
a.GET("/work", func(c *httpx.Context) error {
close(started)
<-c.Context().Done()
close(canceled)
<-release
if closed.Load() != 0 {
t.Error("dependency closed during cancellation cleanup")
}
return nil
})
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- a.ServeContext(ctx, l) }()
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
defer client.CloseIdleConnections()
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
res, err := client.Get("https://" + l.Addr().String() + "/work")
if err == nil {
io.Copy(io.Discard, res.Body)
res.Body.Close()
}
}()
await(t, started)
cancel()
await(t, canceled)
if closed.Load() != 0 {
t.Fatal("dependency closed on cancellation")
}
select {
case err := <-done:
t.Fatal("returned before handler cleanup", err)
default:
}
// Even an in-memory invocation through Engine must now be rejected.
w := httptest.NewRecorder()
a.Engine().ServeHTTP(w, httptest.NewRequest("GET", "/work", nil))
if w.Code != 503 {
t.Fatal(w.Code)
}
unblock()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("shutdown stuck")
}
await(t, requestDone)
if closed.Load() != 1 {
t.Fatal("cleanup count", closed.Load())
}
})
}
}
func TestExternalServingEntriesRetainActiveResources(t *testing.T) {
for _, entry := range []string{"App", "Engine", "NewServer", "RunWith"} {
t.Run(entry, func(t *testing.T) {
a := New(config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 30 * time.Millisecond}))
started, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
var closed atomic.Int32
a.OnShutdown(func() error { closed.Add(1); return nil })
a.GET("/", func(*httpx.Context) error {
close(started)
<-release
if closed.Load() != 0 {
t.Error("closed while active")
}
return nil
})
var h http.Handler = a
if entry == "Engine" {
h = a.Engine()
}
if entry == "NewServer" {
h = a.NewServer("").Handler
}
invoke := func(h http.Handler) {
defer close(returned)
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
}
defer func() { close(release); await(t, returned) }()
var err error
if entry == "RunWith" {
err = a.RunWith(context.Background(), runnerFunc(func(_ context.Context, h http.Handler, _ string) error { go invoke(h); await(t, started); return nil }))
} else {
go invoke(h)
await(t, started)
err = a.Close()
}
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() != 0 {
t.Fatal("retention failed", err, closed.Load())
}
if err = a.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal("repeat close lost retention", err)
}
})
}
}
func TestRequestAdmissionAndCloseRace(t *testing.T) {
for attempt := 0; attempt < 20; attempt++ {
a := New()
var closed atomic.Bool
a.OnShutdown(func() error { closed.Store(true); return nil })
a.GET("/", func(c *httpx.Context) error {
if closed.Load() {
t.Error("handler admitted after resource release")
}
return nil
})
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
a.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
}()
}
close(start)
if err := a.Close(); err != nil {
t.Fatal(err)
}
wg.Wait()
}
}

View File

@@ -10,7 +10,7 @@ import (
)
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
routes.Handle(method, path, a.adapt(handler, middleware...))
routes.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
}
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
@@ -62,7 +62,7 @@ func (a *App) HandleWithResponse(method, path string, encoder response.Encoder,
if encoder == nil {
encoder = a.responseEncoder
}
a.router.Handle(method, path, a.adaptResponse(encoder, handler, middleware...))
a.router.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), encoder, handler, middleware...))
}
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
@@ -86,18 +86,19 @@ func (a *App) DELETE(path string, handler httpx.Handler, middleware ...httpx.Mid
}
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.router.Any(path, a.adapt(handler, middleware...))
a.router.Any(path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
}
// RouterGroup scopes a route prefix and optional middleware.
type RouterGroup struct {
prefix string
group httpx.RouterGroup
app *App
middlewares []httpx.Middleware
}
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware}
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware, prefix: joinRoutePath("", path)}
}
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
@@ -108,7 +109,7 @@ func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined}
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined, prefix: joinRoutePath(g.prefix, path)}
}
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
@@ -128,7 +129,7 @@ func (g *RouterGroup) HandleWithResponse(method, path string, encoder response.E
}
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
g.group.Handle(method, path, g.app.adaptResponse(encoder, handler, combined...))
g.group.Handle(method, path, g.app.bodyHandler(g.app.defaultBodyLimit(), encoder, handler, combined...))
}
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
@@ -154,5 +155,5 @@ func (g *RouterGroup) DELETE(path string, handler httpx.Handler, middleware ...h
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
g.group.Any(path, g.app.adapt(handler, combined...))
g.group.Any(path, g.app.bodyHandler(g.app.defaultBodyLimit(), nil, handler, combined...))
}

View File

@@ -10,13 +10,14 @@ import (
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
// ServeContext serves a caller-created listener using the configured transport
// and TLS. App takes ownership of the listener, including on startup failure,
// and runs resource cleanup after the server has stopped.
// and runs resource cleanup only after admitted handlers have returned.
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
defer func() { runErr = shutdownResult(ctx, runErr) }()
if listener == nil {
@@ -30,6 +31,8 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
_ = listener.Close()
return a.Close()
}
stopWatching := a.watchShutdown(ctx)
defer stopWatching()
serve, err := a.prepareTransport()
if err != nil {
_ = listener.Close()
@@ -39,6 +42,9 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
}
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
if err := a.ValidateHTTP(); err != nil {
return nil, err
}
if err := a.config.ValidateTransport(); err != nil {
return nil, err
}
@@ -57,6 +63,9 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
switch a.config.Transport {
case config.TransportNative:
server := native.New(a)
// App enforces the service/route policy after routing. Keep only the
// supported framing ceiling here, never a hidden smaller transport cap.
server.MaxBodyBytes = httplimits.MaxBodyBytes
server.ShutdownTimeout = shutdownTimeout
cfg := a.config.HTTP
if cfg.ReadHeaderTimeout > 0 {
@@ -82,6 +91,7 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
return server.Serve, nil
case config.TransportHTTP2:
server := h2transport.New(a)
server.MaxBodyBytes = httplimits.MaxBodyBytes
server.ShutdownTimeout = shutdownTimeout
if a.config.HTTP.IdleTimeout > 0 {
server.IdleTimeout = a.config.HTTP.IdleTimeout

View File

@@ -15,6 +15,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func TestConfiguredTransportLifecycle(t *testing.T) {
@@ -269,7 +270,9 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
cert, key, tlsClient := testutil.CertificateFiles(t)
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond))
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 40 * time.Millisecond}))
var cleaned atomic.Int32
application.OnShutdown(func() error { cleaned.Add(1); return nil })
started := make(chan context.Context, 1)
release, finished := make(chan struct{}), make(chan struct{})
application.GET("/blocked", func(ctx *httpx.Context) error {
@@ -304,11 +307,17 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
close(release)
t.Fatal("handler did not start")
}
defer func() { close(release); await(t, finished) }()
defer func() {
close(release)
await(t, finished)
if err := application.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
t.Fatalf("late handler return must not trigger cleanup: %v count=%d", err, cleaned.Load())
}
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
t.Fatalf("shutdown: %v", err)
}
case <-time.After(2 * time.Second):

View File

@@ -1,6 +1,8 @@
# go-omni CLI
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用同版 v0.26 CLI 与框架。
v0.28 新增 `project new/generate/check`:生成无 API、无监听端口和无外部服务依赖的通用 Go 程序,配置/日志/生命周期自动装配,项目维护 svc 与 Run。`project generate --dry-run` 预览,`project check` 只读检查;详见 [通用工程指南](../../PROGRAM.md)。
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
@@ -10,13 +12,13 @@ v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 work
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
## 创建项目
## 创建 API 项目
```sh
# 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -51,14 +53,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
CLI 与框架依赖固定使用 v0.26.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
CLI 与框架依赖固定使用 v0.28.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
```sh
# 在 go-omni 源码仓库中执行
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
```
当前模板与依赖以 v0.26.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
当前模板与依赖以 v0.28.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令

View File

@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
@@ -22,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Runtime-only releases need not raise the managed template compatibility floor.
const minimumVersion = "v0.22.0-preview"
const currentVersion = "v0.26.0-preview"
const currentVersion = "v0.28.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
@@ -345,25 +346,7 @@ func moduleFile(o Options) ([]byte, error) {
}
return []byte(text), nil
}
func canonical(path string) string {
path, _ = filepath.Abs(path)
candidate := path
var tail []string
for {
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
for i := len(tail) - 1; i >= 0; i-- {
resolved = filepath.Join(resolved, tail[i])
}
return resolved
}
parent := filepath.Dir(candidate)
if parent == candidate {
return path
}
tail = append(tail, filepath.Base(candidate))
candidate = parent
}
}
func canonical(path string) string { return genfs.Canonical(path) }
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
generated, seeds := map[string][]byte{}, map[string][]byte{}

View File

@@ -30,12 +30,12 @@ func main(){
func run(ctx context.Context,path string)(runErr error){
cfg,err:=config.Load(path);if err!=nil{return err}
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
// Unjoined background work may still log. The owning process exits on this
// Unjoined HTTP or background work may still log. The owning process exits on this
// error; do not close its borrowed logger before that work has finished.
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
// RunContext drains HTTP requests before returning. This scope owns dependencies
// and closes them exactly once, before closing the borrowed logger.
// Finish propagates incomplete HTTP draining: stop background work but retain
// shared dependencies when any handler is still running. Otherwise close once.
defer func(){runErr=deps.Finish(runErr)}()
app,err:=handler.NewApp(deps);if err!=nil{return err}
defer func(){runErr=errors.Join(runErr,app.Close())}()

View File

@@ -7,6 +7,7 @@ import (
)
var Opened, Closed, DownloadClosed atomic.Int32
var SlowReturned atomic.Int32
var Started = make(chan struct{}, 1)
var Release = make(chan struct{})
var Current atomic.Pointer[ServiceContext]

View File

@@ -71,6 +71,7 @@ type countedReader struct{ io.Reader }
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
defer svc.SlowReturned.Add(1)
before := svc.Closed.Load()
svc.Started <- struct{}{}
<-svc.Release

View File

@@ -19,6 +19,7 @@ import (
"example.com/managed/internal/handler"
"example.com/managed/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/health"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
@@ -229,3 +230,80 @@ func TestGeneratedEndpointConflictCleansUp(t *testing.T) {
t.Fatal("collision leaked resources or started serving", err)
}
}
func TestGeneratedIncompleteHTTPDrainRetainsDependencies(t *testing.T) {
for _, transport := range []string{"nethttp", "native"} {
t.Run(transport, func(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := l.Addr().String()
l.Close()
path := filepath.Join(t.TempDir(), "config.json")
logFile := filepath.Join(t.TempDir(), "service.log")
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address, "transport": transport, "shutdownTimeout": "30ms", "http": map[string]string{"shutdownCancelTimeout": "30ms"}, "log": map[string]any{"file": logFile, "rotation": map[string]any{"enabled": true}}}})
if err = os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
svc.Started = make(chan struct{}, 1)
svc.Release = make(chan struct{})
before := svc.Closed.Load()
returned := svc.SlowReturned.Load()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- run(ctx, path) }()
waitReady(t, "http://"+address, done)
client := &http.Client{Timeout: 3 * time.Second}
defer client.CloseIdleConnections()
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
res, err := client.Get("http://" + address + "/slow")
if err == nil {
io.Copy(io.Discard, res.Body)
res.Body.Close()
}
}()
await(t, svc.Started)
// Capture the channel, because a later test installs its own fixture.
release := svc.Release
deps := svc.Current.Load()
defer func() {
close(release)
deadline := time.Now().Add(3 * time.Second)
for svc.SlowReturned.Load() == returned {
if time.Now().After(deadline) {
t.Error("handler did not exit after test release")
break
}
time.Sleep(time.Millisecond)
}
// The test owns the controlled handler and has now joined it.
_ = deps.Logger.Close()
}()
cancel()
select {
case err = <-done:
if !errors.Is(err, lifecycle.ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("unbounded HTTP shutdown")
}
if err = deps.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
t.Fatal("deferred Close bypassed retention", err)
}
if svc.Closed.Load() != before {
t.Fatal("generated main closed client during active request")
}
deps.Logger.Info("retained.http.logger")
logs, err := os.ReadFile(logFile)
if err != nil || !strings.Contains(string(logs), "retained.http.logger") {
t.Fatal("generated main closed logger during active request", err, string(logs))
}
await(t, requestDone)
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
"os"
"path/filepath"
"sort"
@@ -244,44 +245,13 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
}
return result, checkErr
}
// A rollback restores bytes/modes if an ordinary filesystem write fails.
// File replacement is atomic; the whole directory is not crash-atomic.
var applied []change
transaction := make([]genfs.Change, 0, len(changes))
for _, c := range changes {
if err = safePath(root, c.name); err == nil {
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.name)))
if c.existed {
if e != nil || !bytes.Equal(current, c.before) {
err = fmt.Errorf("file changed during generation: %s", c.name)
}
} else if !errors.Is(e, os.ErrNotExist) {
err = fmt.Errorf("file appeared during generation: %s", c.name)
}
}
if err == nil {
if c.remove {
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.name)))
} else {
err = atomicWrite(root, c.name, c.after, c.mode)
}
}
if err != nil {
for i := len(applied) - 1; i >= 0; i-- {
previous := applied[i]
var rollback error
if previous.existed {
rollback = atomicWrite(root, previous.name, previous.before, previous.mode)
} else {
rollback = os.Remove(filepath.Join(root, filepath.FromSlash(previous.name)))
}
err = errors.Join(err, rollback)
}
return result, err
}
applied = append(applied, c)
transaction = append(transaction, genfs.Change{Name: c.name, Before: c.before, After: c.after, Existed: c.existed, Remove: c.remove, Mode: c.mode})
}
return result, nil
return result, genfs.Apply(root, transaction)
}
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
func ownedPath(name string) bool {
if name == "internal/config/loadGen.go" {
@@ -298,52 +268,7 @@ func ownedPath(name string) bool {
}
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")
}
func safePath(root, name string) error {
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
return fmt.Errorf("api: unsafe output path %q", name)
}
path := root
parts := strings.Split(name, "/")
for i, part := range parts {
path = filepath.Join(path, part)
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("api: refusing symlink %s", path)
}
if i < len(parts)-1 && !info.IsDir() {
return fmt.Errorf("api: expected directory %s", path)
}
if i == len(parts)-1 && !info.Mode().IsRegular() {
return fmt.Errorf("api: expected regular file %s", path)
}
}
return nil
}
func safePath(root, name string) error { return genfs.SafePath(root, name) }
func atomicWrite(root, name string, data []byte, mode os.FileMode) error {
target := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(target), ".apiGen-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if err = f.Chmod(mode); err == nil {
_, err = f.Write(data)
}
if err == nil {
err = f.Sync()
}
err = errors.Join(err, f.Close())
if err != nil {
return err
}
return os.Rename(f.Name(), target)
return genfs.AtomicWrite(root, name, data, mode)
}

View File

@@ -0,0 +1,133 @@
// Package genfs shares guarded filesystem operations between project generators.
package genfs
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
func SafePath(root, name string) error {
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
return fmt.Errorf("generation: unsafe output path %q", name)
}
path := root
parts := strings.Split(name, "/")
for i, part := range parts {
path = filepath.Join(path, part)
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("generation: refusing symlink %s", path)
}
if i < len(parts)-1 && !info.IsDir() {
return fmt.Errorf("generation: expected directory %s", path)
}
if i == len(parts)-1 && !info.Mode().IsRegular() {
return fmt.Errorf("generation: expected regular file %s", path)
}
}
return nil
}
func AtomicWrite(root, name string, data []byte, mode os.FileMode) error {
target := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(target), ".omniGen-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if err = f.Chmod(mode); err == nil {
_, err = f.Write(data)
}
if err == nil {
err = f.Sync()
}
err = errors.Join(err, f.Close())
if err != nil {
return err
}
return os.Rename(f.Name(), target)
}
// Change records the preflight snapshot used for conflict detection and rollback.
type Change struct {
Name string
Before, After []byte
Existed, Remove bool
Mode os.FileMode
}
// Apply replaces individual files atomically and rolls back ordinary write failures.
// Callers serialize generators with a project lock. This is not crash-atomic across files.
func Apply(root string, changes []Change) error {
var applied []Change
for _, c := range changes {
err := SafePath(root, c.Name)
if err == nil {
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.Name)))
if c.Existed {
if e != nil || !bytes.Equal(current, c.Before) {
err = fmt.Errorf("file changed during generation: %s", c.Name)
}
} else if !errors.Is(e, os.ErrNotExist) {
err = fmt.Errorf("file appeared during generation: %s", c.Name)
}
}
if err == nil {
if c.Remove {
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.Name)))
} else {
err = AtomicWrite(root, c.Name, c.After, c.Mode)
}
}
if err != nil {
for i := len(applied) - 1; i >= 0; i-- {
previous := applied[i]
if e := SafePath(root, previous.Name); e != nil {
err = errors.Join(err, e)
continue
}
if previous.Existed {
err = errors.Join(err, AtomicWrite(root, previous.Name, previous.Before, previous.Mode))
} else {
err = errors.Join(err, os.Remove(filepath.Join(root, filepath.FromSlash(previous.Name))))
}
}
return err
}
applied = append(applied, c)
}
return nil
}
// Canonical resolves existing parent symlinks even for an output not yet created.
func Canonical(path string) string {
path, _ = filepath.Abs(path)
candidate := path
var tail []string
for {
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
for i := len(tail) - 1; i >= 0; i-- {
resolved = filepath.Join(resolved, tail[i])
}
return resolved
}
parent := filepath.Dir(candidate)
if parent == candidate {
return path
}
tail = append(tail, filepath.Base(candidate))
candidate = parent
}
}

View File

@@ -0,0 +1,47 @@
package genfs
import (
"os"
"path/filepath"
"testing"
)
func TestConflictRollsBackEarlierWrites(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "one"), []byte("before"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "two"), []byte("concurrent"), 0644); err != nil {
t.Fatal(err)
}
err := Apply(root, []Change{{Name: "one", Before: []byte("before"), After: []byte("after"), Existed: true, Mode: 0600}, {Name: "new", After: []byte("created"), Mode: 0644}, {Name: "two", Before: []byte("stale"), After: []byte("after"), Existed: true, Mode: 0644}})
if err == nil {
t.Fatal("conflict accepted")
}
data, _ := os.ReadFile(filepath.Join(root, "one"))
info, _ := os.Stat(filepath.Join(root, "one"))
if string(data) != "before" || info.Mode().Perm() != 0600 {
t.Fatal("rollback did not restore bytes/mode")
}
if _, err = os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) {
t.Fatal("new output survived rollback")
}
data, _ = os.ReadFile(filepath.Join(root, "two"))
if string(data) != "concurrent" {
t.Fatal("concurrent edit overwritten")
}
}
func TestUnsafePaths(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"../escape", "a/../../escape", "/outside", "a\\b", ".", "a/../b"} {
if SafePath(root, name) == nil {
t.Fatal(name)
}
}
if err := os.Symlink(t.TempDir(), filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
if SafePath(root, "link/file") == nil {
t.Fatal("followed symlink")
}
}

View File

@@ -18,6 +18,11 @@ func main() {
}
switch os.Args[1] {
case "project":
if err := runProject(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "go-omni project:", err)
os.Exit(1)
}
case "i18n":
if err := runI18n(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "go-omni i18n:", err)
@@ -175,6 +180,7 @@ func usage() {
fmt.Fprintln(os.Stderr, "GoOmni project tool")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " go-omni project <new|generate|check> Generate a transport-independent Go program")
fmt.Fprintln(os.Stderr, " go-omni i18n <check|export|types|extract> Validate and export independent language resources")
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] <directory>")
@@ -217,6 +223,9 @@ func runAdd(args []string) error {
return fmt.Errorf("unexpected argument %q", arg)
}
}
if _, err := os.Lstat(filepath.Join(directory, ".goOmni", "projectManifest.json")); !errors.Is(err, os.ErrNotExist) {
return errors.New("plain Go projects add packages as ordinary Go code; use project generate for infrastructure")
}
if _, err := os.Stat(filepath.Join(directory, ".goOmni", "apiManifest.json")); err == nil {
return errors.New("managed API projects add modules through .api imports and api generate")
}

102
cmd/go-omni/project.go Normal file
View File

@@ -0,0 +1,102 @@
package main
import (
"errors"
"fmt"
"strings"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
)
func runProject(args []string) error {
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
projectUsage()
return nil
}
command := args[0]
if command != "new" && command != "generate" && command != "check" {
return fmt.Errorf("unknown project command %q", command)
}
o := projectgen.Options{Check: command == "check"}
for i := 1; i < len(args); i++ {
arg := args[i]
if arg == "--help" || arg == "-h" {
projectUsage()
return nil
}
if arg == "--check" || arg == "--dry-run" {
if command != "generate" {
return fmt.Errorf("%s belongs to project generate", arg)
}
if arg == "--check" {
o.Check = true
} else {
o.DryRun = true
}
continue
}
if !strings.HasPrefix(arg, "-") {
if command != "new" || o.Dir != "" {
return fmt.Errorf("unexpected argument %q", arg)
}
o.Dir = arg
continue
}
name, value, inline := strings.Cut(arg, "=")
switch name {
case "--dir", "--module", "--framework-dir", "--framework-version":
default:
return fmt.Errorf("unknown project option %q", name)
}
if command != "new" && name != "--dir" {
return fmt.Errorf("%s belongs to project new", name)
}
if !inline {
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") {
return fmt.Errorf("%s requires a value", name)
}
i++
value = args[i]
}
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s requires a value", name)
}
switch name {
case "--dir":
if o.Dir != "" {
return errors.New("project directory specified more than once")
}
o.Dir = value
case "--module":
o.Module = value
case "--framework-dir":
o.FrameworkDir = value
case "--framework-version":
o.FrameworkVersion = value
}
}
var out projectgen.Output
var err error
if command == "new" {
out, err = projectgen.New(o)
} else {
out, err = projectgen.Generate(o)
}
if err != nil {
return err
}
if o.DryRun {
for _, change := range out.Changes {
fmt.Printf("%-7s %s\n", change.Action, change.Path)
}
} else {
fmt.Printf("project %s: %d file changes\n", command, len(out.Changes))
}
return nil
}
func projectUsage() {
fmt.Println("go-omni project new <directory> [--module <module>] [--framework-version <version>|--framework-dir <directory>]")
fmt.Println("go-omni project generate [--dir <project>] [--check|--dry-run]")
fmt.Println("go-omni project check [--dir <project>]")
fmt.Println("Creates a plain Go program with config, logging, dependency lifecycle and an editable Run entry.")
}

View File

@@ -0,0 +1,27 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestProjectCLI(t *testing.T) {
root := filepath.Join(t.TempDir(), "program")
for _, args := range [][]string{{"new", root, "--module", "example.com/program"}, {"generate", "--dir", root, "--dry-run"}, {"generate", "--dir", root}, {"check", "--dir", root}, {"generate", "--dir", root, "--check"}} {
if err := runProject(args); err != nil {
t.Fatal(args, err)
}
}
if err := runAdd([]string{"item", "--dir", root}); err == nil {
t.Fatal("add adopted plain project")
}
if _, err := os.Stat(filepath.Join(root, "internal/item")); !os.IsNotExist(err) {
t.Fatal("business module generated")
}
for _, args := range [][]string{{"new"}, {"new", "a", "b"}, {"new", "--api", "x"}, {"check", "--dry-run"}, {"generate", "--check", "--dry-run"}, {"generate", "--module", "x"}, {"new", "--module"}, {"new", "--dir="}, {"new", "--dir", "x", "y"}, {"unknown"}} {
if err := runProject(args); err == nil {
t.Fatal("accepted", args)
}
}
}

View File

@@ -0,0 +1,190 @@
package projectgen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestGeneratedProgramConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
root := filepath.Join(t.TempDir(), "program")
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
if cli != "" {
runCommand(t, "", cli, "project", "new", root, "--module", "example.com/plain")
} else {
framework, err := filepath.Abs("../../..")
if err != nil {
t.Fatal(err)
}
if _, err = New(Options{Dir: root, Module: "example.com/plain", FrameworkDir: framework}); err != nil {
t.Fatal(err)
}
}
// Only project-owned files are customized. All entry/loading/lifecycle code
// remains exactly what the local or downloaded CLI generated.
svc := get(t, root, "internal/svc/serviceContext.go")
svc = strings.Replace(svc, "return nil", "return Initialize(ctx, c)", 1)
svc += "\nvar Initialize = func(context.Context, *ServiceContext) error { return nil }\n"
put(t, root, "internal/svc/serviceContext.go", svc)
put(t, root, "internal/run/run.go", `package run
import("context";"errors";"os";"example.com/plain/internal/svc")
var Execute=func(ctx context.Context,deps *svc.ServiceContext)error{
switch os.Getenv("GO_OMNI_PROGRAM_TEST_MODE") {case "error":return errors.New("process failure marker");case "panic":panic("process panic marker");case "unresponsive":select{}}
<-ctx.Done();return nil
}
func Run(ctx context.Context,deps *svc.ServiceContext)error{return Execute(ctx,deps)}
`)
put(t, root, "consumer_test.go", get(t, "testdata", "consumer_test.go"))
runCommand(t, root, "go", "mod", "tidy")
runCommand(t, root, "go", "test", "-race", "-count=1", "./...")
runCommand(t, root, "go", "vet", "./...")
binary := filepath.Join(root, "program")
runCommand(t, root, "go", "build", "-race", "-o", binary, ".")
before := snapshot(t, root)
for _, mode := range []string{"generate", "check", "dry-run"} {
if cli != "" {
args := []string{"project", mode, "--dir", root}
if mode == "dry-run" {
args = []string{"project", "generate", "--dir", root, "--dry-run"}
}
runCommand(t, root, cli, args...)
} else {
if _, err := Generate(Options{Dir: root, Check: mode == "check", DryRun: mode == "dry-run"}); err != nil {
t.Fatal(err)
}
}
}
after := snapshot(t, root)
for name, data := range before {
if after[name] != data {
t.Fatal("consumer changed during regeneration", name)
}
}
// Validation must not open even the configured log file.
logfile := filepath.Join(root, "check-must-not-open.log")
cfg := filepath.Join(root, "check.json")
raw, _ := json.Marshal(map[string]any{"log": map[string]any{"file": logfile}})
put(t, root, "check.json", string(raw))
runCommand(t, root, binary, "-config", cfg, "-check-config")
if _, err := os.Stat(logfile); !os.IsNotExist(err) {
t.Fatal("check-config opened logger", err)
}
put(t, root, "check.json", `{"log":{"level":"invalid"}}`)
cmd := exec.Command(binary, "-config", cfg, "-check-config")
cmd.Dir = root
if data, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(data), "log.level") {
t.Fatal("invalid config accepted", string(data), err)
}
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
testProcess(t, root, binary, sig, "")
}
for _, mode := range []string{"error", "panic", "unresponsive"} {
var sig os.Signal
if mode == "unresponsive" {
sig = syscall.SIGTERM
}
testProcess(t, root, binary, sig, mode)
}
if cli != "" {
data := runCommand(t, root, "go", "list", "-m", "-json", frameworkModule)
var dep struct {
Version, Sum string
Replace any
}
if err := json.Unmarshal([]byte(data), &dep); err != nil {
t.Fatal(err)
}
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Replace != nil || dep.Sum == "" {
t.Fatal("not a fixed remote consumer", dep)
}
t.Logf("remote framework %s checksum %s; no local replace", dep.Version, dep.Sum)
}
}
func runCommand(t *testing.T, dir, name string, args ...string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
if dir != "" {
cmd.Dir = dir
}
cmd.Env = append(os.Environ(), "GOWORK=off")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, output)
}
t.Logf("%s %v\n%s", name, args, output)
return string(output)
}
func testProcess(t *testing.T, root, binary string, sig os.Signal, mode string) {
t.Helper()
logfile, err := os.CreateTemp(root, "signal-*.log")
if err != nil {
t.Fatal(err)
}
defer logfile.Close()
cfg := filepath.Join(root, "process.json")
put(t, root, "process.json", `{"lifecycle":{"stopTimeout":"100ms","drainTimeout":"30ms","cancelTimeout":"100ms"}}`)
cmd := exec.Command(binary, "-config", cfg)
cmd.Env = append(os.Environ(), "GO_OMNI_PROGRAM_TEST_MODE="+mode)
cmd.Dir = root
cmd.Stdout = logfile
cmd.Stderr = logfile
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
defer func() { _ = cmd.Process.Kill() }()
deadline := time.Now().Add(10 * time.Second)
for {
data, _ := os.ReadFile(logfile.Name())
if strings.Contains(string(data), "program.starting") {
break
}
if time.Now().After(deadline) {
t.Fatal("program did not start", string(data))
}
time.Sleep(10 * time.Millisecond)
}
if path, err := exec.LookPath("lsof"); err == nil {
output, _ := exec.Command(path, "-nP", "-a", "-p", strconv.Itoa(cmd.Process.Pid), "-i").CombinedOutput()
if strings.Contains(string(output), "TCP") || strings.Contains(string(output), "UDP") {
t.Fatal("starter opened network socket", string(output))
}
}
if sig != nil {
if err = cmd.Process.Signal(sig); err != nil {
t.Fatal(err)
}
}
select {
case err = <-done:
data, _ := os.ReadFile(logfile.Name())
if mode == "" && err != nil {
t.Fatal("signal exit failed", err, string(data))
}
if mode != "" {
exit, ok := err.(*exec.ExitError)
if !ok || exit.ExitCode() != 1 {
t.Fatal("abnormal process exit status", err, string(data))
}
}
if mode == "unresponsive" && !strings.Contains(string(data), "shared resources retained") {
t.Fatal("timeout did not retain resources", string(data))
}
t.Logf("process mode=%q signal=%v passed", mode, sig)
case <-time.After(10 * time.Second):
t.Fatal("signal exit timed out")
}
}

View File

@@ -0,0 +1,359 @@
// Package projectgen generates transport-independent Go programs.
package projectgen
import (
"bytes"
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"go/format"
"io"
"os"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"strings"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const currentVersion = "v0.28.0-preview"
const minimumVersion = "v0.28.0-preview"
const manifestPath = ".goOmni/projectManifest.json"
const lockPath = ".goOmni/project.lock"
const header = "// Code generated by go-omni project. DO NOT EDIT.\n"
//go:embed templates/*
var templates embed.FS
var managed = map[string]bool{"main.go": true, "internal/config/loadGen.go": true, "internal/svc/runtimeGen.go": true}
var seeds = []string{"go.mod", ".gitignore", "README.md", "etc/config.yaml", "internal/config/config.go", "internal/config/loader.go", "internal/svc/serviceContext.go", "internal/run/run.go"}
type Options struct {
Dir, Module, FrameworkDir, FrameworkVersion string
Check, DryRun bool
}
type FileChange struct{ Path, Action string }
type Output struct{ Changes []FileChange }
type manifest struct {
Version int `json:"version"`
Kind string `json:"kind"`
TemplateVersion string `json:"templateVersion"`
Module string `json:"module"`
Generated map[string]string `json:"generated"`
}
// New only accepts an empty directory. FrameworkDir is an explicit development opt-in.
func New(o Options) (Output, error) {
if o.Dir == "" || o.Check || o.DryRun {
return Output{}, errors.New("project new requires a directory and does not accept --check/--dry-run")
}
if entries, err := os.ReadDir(o.Dir); err == nil && len(entries) > 0 {
return Output{}, errors.New("project new requires an empty directory")
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return Output{}, err
}
if o.Module == "" {
o.Module = filepath.Base(genfs.Canonical(o.Dir))
}
if err := module.CheckImportPath(o.Module); err != nil {
return Output{}, err
}
if _, err := moduleFile(o, genfs.Canonical(o.Dir), true); err != nil {
return Output{}, err
}
return generate(o, true)
}
func Generate(o Options) (Output, error) { return generate(o, false) }
func generate(o Options, create bool) (out Output, err error) {
if o.Check && o.DryRun {
return out, errors.New("--check and --dry-run cannot be combined")
}
if o.Dir == "" {
o.Dir = "."
}
root, err := filepath.Abs(o.Dir)
if err != nil {
return out, err
}
if info, e := os.Lstat(root); e == nil {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return out, errors.New("project directory must be a real directory")
}
} else if !create || !errors.Is(e, os.ErrNotExist) {
return out, e
}
root = genfs.Canonical(root)
for _, name := range append(append([]string{}, seeds...), manifestPath, lockPath) {
if err = genfs.SafePath(root, name); err != nil {
return out, err
}
}
for name := range managed {
if err = genfs.SafePath(root, name); err != nil {
return out, err
}
}
for _, other := range []string{".goOmni/apiManifest.json", "api/service.api"} {
if _, e := os.Lstat(filepath.Join(root, other)); !errors.Is(e, os.ErrNotExist) {
return out, errors.New("project generator requires its own project; existing API layout is not adopted")
}
}
if !o.Check && !o.DryRun {
if err = os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
return out, err
}
lock, e := os.OpenFile(filepath.Join(root, lockPath), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
return out, fmt.Errorf("acquire project.lock (check for a running generator before removing a stale lock): %w", e)
}
_, err = fmt.Fprintf(lock, "pid=%d\n", os.Getpid())
err = errors.Join(err, lock.Close())
defer os.Remove(filepath.Join(root, lockPath))
if err != nil {
return out, err
}
}
var old manifest
oldData, readErr := os.ReadFile(filepath.Join(root, manifestPath))
if create {
if !errors.Is(readErr, os.ErrNotExist) {
return out, errors.New("project manifest appeared during creation")
}
// Recheck after taking the lock; never adopt files created concurrently.
entries, e := os.ReadDir(root)
if e != nil {
return out, e
}
for _, entry := range entries {
if entry.Name() != ".goOmni" {
return out, errors.New("project directory changed during creation")
}
}
entries, e = os.ReadDir(filepath.Join(root, ".goOmni"))
if e != nil {
return out, e
}
for _, entry := range entries {
if entry.Name() != "project.lock" {
return out, errors.New("project metadata appeared during creation")
}
}
} else {
if readErr != nil {
return out, fmt.Errorf("read project manifest (create with project new first): %w", readErr)
}
decoder := json.NewDecoder(bytes.NewReader(oldData))
decoder.DisallowUnknownFields()
if err = decoder.Decode(&old); err != nil {
return out, fmt.Errorf("invalid project manifest: %w", err)
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return out, errors.New("invalid trailing project manifest data")
}
if old.Version != 1 || old.Kind != "project" || old.Module == "" || len(old.Generated) != len(managed) || !semver.IsValid(old.TemplateVersion) || semver.Compare(old.TemplateVersion, minimumVersion) > 0 {
return out, errors.New("unsupported project manifest; use a compatible CLI")
}
if o.Module != "" && o.Module != old.Module {
return out, errors.New("module differs from project manifest")
}
o.Module = old.Module
for name := range managed {
if len(old.Generated[name]) != 64 {
return out, fmt.Errorf("invalid manifest ownership for %s", name)
}
}
}
if o.Module == "" {
o.Module = filepath.Base(root)
}
if err = module.CheckImportPath(o.Module); err != nil {
return out, err
}
moduleData, err := moduleFile(o, root, create)
if err != nil {
return out, err
}
rendered, err := render(o.Module, moduleData)
if err != nil {
return out, err
}
next := manifest{Version: 1, Kind: "project", TemplateVersion: minimumVersion, Module: o.Module, Generated: map[string]string{}}
var changes []genfs.Change
names := make([]string, 0, len(rendered))
for name := range rendered {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
target := filepath.Join(root, filepath.FromSlash(name))
before, e := os.ReadFile(target)
exists := e == nil
if e != nil && !errors.Is(e, os.ErrNotExist) {
return out, e
}
generated := managed[name]
if !generated && !create {
if !exists {
return out, fmt.Errorf("project-owned file %s is missing; restore it explicitly", name)
}
continue
}
if generated {
next.Generated[name] = digest(rendered[name])
if exists && (create || digest(before) != old.Generated[name]) {
return out, fmt.Errorf("generated file %s was edited; move custom code to project-owned files", name)
}
} else if exists {
return out, fmt.Errorf("refusing to adopt %s", name)
}
if exists && bytes.Equal(before, rendered[name]) {
continue
}
mode := os.FileMode(0644)
if exists {
info, e := os.Stat(target)
if e != nil {
return out, e
}
mode = info.Mode().Perm()
}
changes = append(changes, genfs.Change{Name: name, Before: before, After: rendered[name], Existed: exists, Mode: mode})
}
data, err := json.MarshalIndent(next, "", " ")
if err != nil {
return out, err
}
data = append(data, '\n')
if !bytes.Equal(oldData, data) {
changes = append(changes, genfs.Change{Name: manifestPath, Before: oldData, After: data, Existed: oldData != nil, Mode: 0644})
}
for _, c := range changes {
action := "create"
if c.Existed {
action = "update"
}
out.Changes = append(out.Changes, FileChange{c.Name, action})
}
sort.Slice(out.Changes, func(i, j int) bool { return out.Changes[i].Path < out.Changes[j].Path })
if o.DryRun {
return out, nil
}
if o.Check {
if len(changes) > 0 {
return out, fmt.Errorf("project generation drift: %v", out.Changes)
}
return out, nil
}
return out, genfs.Apply(root, changes)
}
func moduleFile(o Options, root string, create bool) ([]byte, error) {
if !create {
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
if err != nil {
return nil, err
}
mf, err := modfile.Parse("go.mod", data, nil)
if err != nil {
return nil, err
}
if mf.Module == nil || mf.Module.Mod.Path != o.Module {
return nil, errors.New("go.mod module differs from project manifest")
}
if o.FrameworkDir != "" || o.FrameworkVersion != "" {
return nil, errors.New("framework options belong to project new; update go.mod explicitly")
}
for _, r := range mf.Require {
if r.Mod.Path == frameworkModule && semver.Compare(r.Mod.Version, minimumVersion) >= 0 {
return data, nil
}
}
return nil, fmt.Errorf("go.mod must require go-omni %s or newer", minimumVersion)
}
version := o.FrameworkVersion
if version == "" {
version = currentVersion
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
version = info.Main.Version
}
}
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" || semver.Compare(version, minimumVersion) < 0 {
return nil, fmt.Errorf("framework version must be %s or newer", minimumVersion)
}
mf := new(modfile.File)
if err := mf.AddModuleStmt(o.Module); err != nil {
return nil, err
}
if err := mf.AddGoStmt("1.22.0"); err != nil {
return nil, err
}
if err := mf.AddRequire(frameworkModule, version); err != nil {
return nil, err
}
if o.FrameworkDir != "" {
local, err := filepath.Abs(o.FrameworkDir)
if err != nil {
return nil, err
}
data, err := os.ReadFile(filepath.Join(local, "go.mod"))
if err != nil {
return nil, err
}
source, err := modfile.Parse("go.mod", data, nil)
if err != nil || source.Module == nil || source.Module.Mod.Path != frameworkModule {
return nil, errors.New("--framework-dir must contain go-omni")
}
local = genfs.Canonical(local)
rel, err := filepath.Rel(root, local)
if err != nil {
return nil, err
}
rel = filepath.ToSlash(rel)
if !strings.HasPrefix(rel, ".") {
rel = "./" + rel
}
if err = mf.AddReplace(frameworkModule, "", rel, ""); err != nil {
return nil, err
}
}
return mf.Format()
}
func render(moduleName string, moduleData []byte) (map[string][]byte, error) {
files := map[string][]byte{"go.mod": moduleData}
paths := append([]string{}, seeds[1:]...)
for name := range managed {
paths = append(paths, name)
}
for _, name := range paths {
raw, err := templates.ReadFile("templates/" + strings.ReplaceAll(name, "/", "_") + ".tmpl")
if err != nil {
return nil, err
}
s := strings.ReplaceAll(string(raw), "\"PROJECT_MODULE\"", strconv.Quote(moduleName))
s = strings.ReplaceAll(s, "PROJECT_MODULE/", moduleName+"/")
if managed[name] {
s = header + s
}
data := []byte(s)
if strings.HasSuffix(name, ".go") {
data, err = format.Source(data)
if err != nil {
return nil, fmt.Errorf("format %s: %w", name, err)
}
}
files[name] = data
}
return files, nil
}
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }

View File

@@ -0,0 +1,215 @@
package projectgen
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func put(t *testing.T, root, name, content string) {
t.Helper()
p := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func get(t *testing.T, root, name string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Fatal(err)
}
return string(data)
}
func snapshot(t *testing.T, root string) map[string]string {
t.Helper()
files := map[string]string{}
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
data, e := os.ReadFile(path)
if e != nil {
return e
}
rel, _ := filepath.Rel(root, path)
files[rel] = string(data)
}
return nil
})
if err != nil {
t.Fatal(err)
}
return files
}
func fresh(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), "program")
if _, err := New(Options{Dir: root, Module: "example.com/program"}); err != nil {
t.Fatal(err)
}
return root
}
func TestOwnershipAndDrift(t *testing.T) {
root := fresh(t)
files := snapshot(t, root)
for name, source := range files {
if strings.HasSuffix(name, ".api") || strings.Contains(name, "handler") || strings.Contains(name, "openapi") {
t.Fatal("unexpected API output", name)
}
if strings.HasSuffix(name, ".go") {
for _, forbidden := range []string{"net/http", "httpx", "middleware", "ListenAndServe", "database/", "queue/", "task.New"} {
if strings.Contains(source, forbidden) {
t.Fatal(name, forbidden)
}
}
}
}
if strings.Contains(files["go.mod"], "replace") || !strings.Contains(files["go.mod"], currentVersion) {
t.Fatal("default dependency must be remote and fixed")
}
for _, name := range seeds {
if name != "go.mod" {
put(t, root, name, get(t, root, name)+"\n// project edit\n")
}
}
before := snapshot(t, root)
for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} {
out, err := Generate(o)
if err != nil || len(out.Changes) != 0 {
t.Fatal(out, err)
}
}
if !reflect.DeepEqual(before, snapshot(t, root)) {
t.Fatal("project files changed")
}
original := get(t, root, "main.go")
put(t, root, "main.go", original+"\n// manual edit\n")
for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} {
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "was edited") {
t.Fatal(err)
}
}
put(t, root, "main.go", original)
if err := os.Remove(filepath.Join(root, "main.go")); err != nil {
t.Fatal(err)
}
before = snapshot(t, root)
if _, err := Generate(Options{Dir: root, Check: true}); err == nil {
t.Fatal("missing output accepted")
}
if !reflect.DeepEqual(before, snapshot(t, root)) {
t.Fatal("check wrote files")
}
out, err := Generate(Options{Dir: root, DryRun: true})
if err != nil || len(out.Changes) != 1 {
t.Fatal(out, err)
}
if !reflect.DeepEqual(before, snapshot(t, root)) {
t.Fatal("dry-run wrote files")
}
if _, err = Generate(Options{Dir: root}); err != nil {
t.Fatal(err)
}
if get(t, root, "main.go") != original {
t.Fatal("missing output not restored")
}
if err = os.Remove(filepath.Join(root, "internal/run/run.go")); err != nil {
t.Fatal(err)
}
if _, err = Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), "project-owned") {
t.Fatal("silently recreated handwritten file", err)
}
}
func TestRecordedTemplateUpgrade(t *testing.T) {
root := fresh(t)
var m manifest
if err := json.Unmarshal([]byte(get(t, root, manifestPath)), &m); err != nil {
t.Fatal(err)
}
original := get(t, root, "main.go")
older := original + "\n// previous generator baseline\n"
put(t, root, "main.go", older)
m.Generated["main.go"] = digest([]byte(older))
data, _ := json.Marshal(m)
put(t, root, manifestPath, string(data))
before := snapshot(t, root)
if _, err := Generate(Options{Dir: root, Check: true}); err == nil {
t.Fatal("stale baseline accepted")
}
if !reflect.DeepEqual(before, snapshot(t, root)) {
t.Fatal("check mutated baseline")
}
if _, err := Generate(Options{Dir: root}); err != nil {
t.Fatal(err)
}
if get(t, root, "main.go") != original {
t.Fatal("template was not updated")
}
}
func TestProjectGuards(t *testing.T) {
for _, tc := range []struct {
name, needle string
mutate func(*testing.T, string)
}{
{"module", "module differs", func(t *testing.T, r string) {
put(t, r, "go.mod", strings.Replace(get(t, r, "go.mod"), "example.com/program", "example.com/other", 1))
}},
{"oldFramework", "must require", func(t *testing.T, r string) {
put(t, r, "go.mod", strings.ReplaceAll(get(t, r, "go.mod"), currentVersion, "v0.22.0-preview"))
}},
{"manifest", "unsupported", func(t *testing.T, r string) { put(t, r, manifestPath, `{"version":1,"kind":"api"}`) }},
{"lock", "project.lock", func(t *testing.T, r string) { put(t, r, lockPath, "occupied") }},
{"otherGenerator", "existing API", func(t *testing.T, r string) { put(t, r, ".goOmni/apiManifest.json", "{}") }},
{"symlink", "symlink", func(t *testing.T, r string) {
if e := os.Remove(filepath.Join(r, "main.go")); e != nil {
t.Fatal(e)
}
target := filepath.Join(t.TempDir(), "outside.go")
put(t, filepath.Dir(target), "outside.go", "outside")
if e := os.Symlink(target, filepath.Join(r, "main.go")); e != nil {
t.Fatal(e)
}
}},
} {
t.Run(tc.name, func(t *testing.T) {
root := fresh(t)
tc.mutate(t, root)
before := snapshot(t, root)
if _, err := Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), tc.needle) {
t.Fatal(err)
}
if !reflect.DeepEqual(before, snapshot(t, root)) {
t.Fatal("failed generation changed files")
}
})
}
root := fresh(t)
before := snapshot(t, root)
if _, err := New(Options{Dir: root}); err == nil {
t.Fatal("new overwrote project")
}
if !reflect.DeepEqual(before, snapshot(t, root)) {
t.Fatal("new mutated project")
}
if _, err := Generate(Options{Dir: t.TempDir(), Check: true}); err == nil {
t.Fatal("adopted unmanaged directory")
}
for _, version := range []string{"v0.27.0-preview", "latest", "v2.0.0", "v0.28.0-preview\nreplace bad"} {
if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), FrameworkVersion: version}); err == nil {
t.Fatal("invalid version", version)
}
}
if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), Module: "example.com/a\nreplace bad"}); err == nil {
t.Fatal("invalid module")
}
}

View File

@@ -0,0 +1,6 @@
/program
/var/
.env
.env.*
!.env.example
/.goOmni/project.lock

View File

@@ -0,0 +1,55 @@
# Go program
This project has no HTTP listener, routes, tasks or external dependencies configured.
```sh
go mod tidy
go run . -check-config
go run .
# SIGINT/SIGTERM stops the program.
go build -o program .
go-omni project generate --dry-run
go-omni project generate
go-omni project check
go test ./...
```
Use the same fixed CLI and framework version. Local --framework-dir is only for
explicit source development; normal creation pins the installed CLI version.
Edit internal/config/config.go (Config/Default/Validate), etc/config.yaml and
internal/config/loader.go for optional decoders. JSON/YAML/XML resolve only
explicit ${NAME} / ${NAME:-fallback}; fixed values are never implicitly overridden.
-check-config validates without opening the logger or dependencies.
Add dependencies in internal/svc/serviceContext.go, registering OnClose immediately.
Register all OnStop/OnDrain hooks in Init, before starting work. Paired hooks use the
same name. OnStop stops acquisition; OnDrain returns nil only after all processing,
acknowledgements and defers finish. Runtime executes stop, then drain, then resource
cleanup in reverse registration order. Callbacks within a phase run concurrently;
combine components with ordering requirements in a single hook. Accepted work uses
WorkContext; Context is the early stop notification. No automatic retry or restart
policy is installed. Adapt external components according to their actual semantics.
Write program behavior in internal/run/run.go. Return nil when a one-shot run finishes;
return an error for fatal failure. The default Run waits for shutdown. Components may
call RequestShutdown(err) to report fatal failure. Signal or Run completion starts
shutdown; the entry waits for Run itself and registered background work. Do not make
Run wait for resource cleanup: cleanup follows Run completion. Do not register hooks
concurrently from Run. The drain name go-omni/project/run is reserved.
Run/Init panics are converted to startup/run failures and registered resources are
cleaned safely. Panics in arbitrary project goroutines must be handled by the project.
Unresponsive work is bounded by lifecycle timeouts: unfinished work causes
ErrResourcesRetained, skips shared resource/logger cleanup and exits unsuccessfully.
Go cannot forcibly stop a goroutine. SIGKILL/process crashes cannot run cleanup.
main.go, internal/config/loadGen.go and internal/svc/runtimeGen.go are generator-owned.
Manual edits cause a conflict. Other starter files are project-owned and preserved;
missing project-owned files require explicit restoration. Check is read-only and
reports missing or stale generated files. It does not compile or execute project code.
Commit .goOmni/projectManifest.json with the project. Generation uses a project lock,
preflight checks and per-file atomic replacement with rollback for ordinary write errors;
it is not crash-atomic across files. Avoid editing files during generation.
Full guide: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/PROGRAM.md

View File

@@ -0,0 +1,9 @@
# Project-owned. Environment references are explicit: ${NAME} or ${NAME:-fallback}.
log:
level: info
format: json
output: stderr
lifecycle:
stopTimeout: 5s
drainTimeout: 10s
cancelTimeout: 5s

View File

@@ -0,0 +1,22 @@
package config
import (
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
// Config, Default and Validate are project-owned. Add project settings here.
type Config struct {
Log logx.Config `json:"log"`
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
}
func Default() Config {
log:=logx.Default();log.Service="PROJECT_MODULE"
return Config{Log:log,Lifecycle:lifecycle.DefaultShutdownConfig()}
}
func(c Config)Validate()error {
if err:=c.Log.Validate();err!=nil{return omniConfig.AtField("log",err)}
if err:=c.Lifecycle.Validate();err!=nil{return omniConfig.AtField("lifecycle",err)}
return nil
}

View File

@@ -0,0 +1,6 @@
package config
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
func Load(path string)(Config,error){
loader,err:=NewLoader();if err!=nil{return Config{},err}
return omniConfig.LoadProject(path,Default(),loader)
}

View File

@@ -0,0 +1,4 @@
package config
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
// NewLoader is project-owned. Register additional decoders here if needed.
func NewLoader()(*omniConfig.Loader,error){return omniConfig.NewLoader()}

View File

@@ -0,0 +1,13 @@
package run
import (
"context"
"PROJECT_MODULE/internal/svc"
)
// Run is project-owned. Return nil for normal completion, or an error to fail.
// The starter waits for shutdown; a one-shot program can simply return when done.
// Register background stop/drain hooks in svc.Init, not concurrently here.
// Accepted background work uses deps.WorkContext(), not the early-stop ctx.
func Run(ctx context.Context,deps *svc.ServiceContext)error {
<-ctx.Done()
return nil
}

View File

@@ -0,0 +1,23 @@
package svc
import (
"context"
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
"PROJECT_MODULE/internal/config"
)
func New(ctx context.Context,logger *logx.Logger,cfg config.Config)(result *ServiceContext,err error){
if ctx==nil{ctx=context.Background()}
if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))}
if logger==nil{return nil,errors.New("service context requires a logger")}
if err=cfg.Validate();err!=nil{return nil,err}
runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Lifecycle);if err!=nil{return nil,err}
c:=&ServiceContext{Runtime:runtime,Config:cfg,Logger:logger}
defer func(){
if p:=recover();p!=nil{err=fmt.Errorf("dependency initialization panic: %v",p)}
if err!=nil{result=nil;err=c.Finish(err)}
}()
if err=c.Init(c.Context());err!=nil{return nil,err}
return c,nil
}

View File

@@ -0,0 +1,21 @@
package svc
import (
"context"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
"PROJECT_MODULE/internal/config"
)
// ServiceContext is project-owned. Add dependencies without global variables.
// Runtime owns registered resources; Logger is borrowed from the entry point.
type ServiceContext struct {
*lifecycle.Runtime
Config config.Config
Logger *logx.Logger
}
// Init runs before Run. Register resources immediately with OnClose.
// Register paired OnStop/OnDrain hooks before exposing background work.
// Use WorkContext for accepted work; Context is the early stop notification.
// OnDrain returns nil only after processing and final cleanup have completed.
// Complete all registrations here, before Run can race with shutdown.
// The drain name "go-omni/project/run" is reserved for the generated entry.
func(c *ServiceContext)Init(ctx context.Context)error{return nil}

View File

@@ -0,0 +1,60 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
"PROJECT_MODULE/internal/config"
program "PROJECT_MODULE/internal/run"
"PROJECT_MODULE/internal/svc"
)
func main() {
path:=flag.String("config","etc/config.yaml","configuration file; empty uses Default()")
check:=flag.Bool("check-config",false,"validate configuration without initializing dependencies")
flag.Parse()
if *check {if _,err:=config.Load(*path);err!=nil{log.Fatal(err)};return}
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
defer stop()
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
}
func run(ctx context.Context,path string)(runErr error) {
cfg,err:=config.Load(path);if err!=nil{return err}
logger,err:=logx.New(cfg.Log);if err!=nil{return err}
var deps *svc.ServiceContext
defer func(){
if p:=recover();p!=nil{runErr=fmt.Errorf("program initialization panic: %v",p)}
if deps!=nil{runErr=deps.Finish(runErr)}
// Unjoined work may still use its dependencies and logger. The process exits
// unsuccessfully; resource retention never means the work completed.
if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}
}()
deps,err=svc.New(ctx,logger,cfg);if err!=nil{return err}
if deps.Context().Err()!=nil{return nil}
done:=make(chan struct{})
// This reserved drain proves Run has actually returned, including its defers.
// Runtime bounds the wait even when Run ignores cancellation.
deps.OnDrain("go-omni/project/run",func(context.Context)error{<-done;return nil})
logger.InfoContext(deps.Context(),"program.starting")
go func(){
var result error
returned:=false
defer func(){
if p:=recover();p!=nil{result=fmt.Errorf("program run panic: %v",p)} else if !returned{result=errors.New("program run exited without returning")}
deps.RequestShutdown(result)
close(done)
}()
result=program.Run(deps.Context(),deps)
returned=true
}()
<-deps.Context().Done()
return nil
}

View File

@@ -0,0 +1,283 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"example.com/plain/internal/config"
program "example.com/plain/internal/run"
"example.com/plain/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func setup(t *testing.T) {
t.Helper()
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
t.Cleanup(func() {
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
})
}
func shortConfig(t *testing.T, logfile string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "config.yaml")
text := "log:\n output: discard\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
if logfile != "" {
text = "log:\n file: " + logfile + "\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
}
if err := os.WriteFile(p, []byte(text), 0600); err != nil {
t.Fatal(err)
}
return p
}
func TestFiniteRunAndCleanupOrder(t *testing.T) {
setup(t)
var events []string
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
events = append(events, "init")
c.OnClose(func() error { events = append(events, "first"); return nil })
c.OnClose(func() error { events = append(events, "second"); return nil })
return nil
}
program.Execute = func(context.Context, *svc.ServiceContext) error {
defer func() { events = append(events, "run defer") }()
events = append(events, "run")
return nil
}
if err := run(context.Background(), ""); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(events, []string{"init", "run", "run defer", "second", "first"}) {
t.Fatal(events)
}
}
func TestInitializationAndRunFailures(t *testing.T) {
for _, stage := range []string{"init error", "init panic", "run error", "run panic", "run goexit", "component failure"} {
t.Run(stage, func(t *testing.T) {
setup(t)
boom := errors.New("failure marker")
var closed atomic.Int32
var started atomic.Bool
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
c.OnClose(func() error { closed.Add(1); return nil })
if stage == "init error" {
return boom
}
if stage == "init panic" {
panic(boom)
}
return nil
}
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
started.Store(true)
switch stage {
case "run error":
return boom
case "run panic":
panic(boom)
case "run goexit":
runtime.Goexit()
case "component failure":
c.RequestShutdown(boom)
<-ctx.Done()
}
return nil
}
err := run(context.Background(), "")
if err == nil || closed.Load() != 1 {
t.Fatal(stage, err, closed.Load())
}
if strings.HasPrefix(stage, "init") && started.Load() {
t.Fatal("run started after failed initialization")
}
if strings.Contains(stage, "error") || stage == "component failure" {
if !errors.Is(err, boom) {
t.Fatal("failure cause lost", err)
}
}
})
}
}
func TestGracefulStopDrainsAcknowledgementBeforeClose(t *testing.T) {
setup(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan struct{})
stop := make(chan struct{})
done := make(chan struct{})
var closed, acked atomic.Bool
var canceledEarly atomic.Bool
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
c.OnClose(func() error {
if !acked.Load() {
return errors.New("closed before acknowledgement")
}
closed.Store(true)
return nil
})
c.OnStop("component", func(context.Context) error { close(stop); return nil })
c.OnDrain("component", func(context.Context) error { <-done; return nil })
go func() {
defer close(done)
<-stop
if c.WorkContext().Err() != nil {
canceledEarly.Store(true)
}
time.Sleep(20 * time.Millisecond)
acked.Store(true)
}()
return nil
}
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { close(ready); <-ctx.Done(); return nil }
result := make(chan error, 1)
go func() { result <- run(ctx, "") }()
<-ready
cancel()
if err := <-result; err != nil || !closed.Load() || canceledEarly.Load() {
t.Fatal(err, closed.Load(), canceledEarly.Load())
}
}
func TestInitializationFailureDrainsStartedWork(t *testing.T) {
setup(t)
done := make(chan struct{})
stop := make(chan struct{})
var closed atomic.Bool
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
c.OnClose(func() error {
select {
case <-done:
closed.Store(true)
return nil
default:
return errors.New("resource closed while starting work still active")
}
})
c.OnStop("partial", func(context.Context) error { close(stop); return nil })
c.OnDrain("partial", func(context.Context) error { <-done; return nil })
go func() { defer close(done); <-stop; time.Sleep(10 * time.Millisecond) }()
return errors.New("later dependency failed")
}
if err := run(context.Background(), ""); err == nil || !closed.Load() {
t.Fatal(err, closed.Load())
}
}
func TestCancellationGraceJoinsWork(t *testing.T) {
setup(t)
done := make(chan struct{})
var closed atomic.Bool
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
c.OnClose(func() error { closed.Store(true); return nil })
c.OnStop("cooperative", func(context.Context) error { return nil })
c.OnDrain("cooperative", func(context.Context) error { <-done; return nil })
go func() { <-c.WorkContext().Done(); close(done) }()
return nil
}
err := run(context.Background(), shortConfig(t, ""))
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) || !closed.Load() {
t.Fatal(err, closed.Load())
}
}
func TestUnjoinedWorkRetainsDependenciesAndLogger(t *testing.T) {
for _, which := range []string{"run", "background", "init failure"} {
t.Run(which, func(t *testing.T) {
setup(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release := make(chan struct{})
joined := make(chan struct{})
ready := make(chan struct{})
var closed atomic.Bool
var held *svc.ServiceContext
logfile := filepath.Join(t.TempDir(), "retained.log")
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
held = c
c.OnClose(func() error { closed.Store(true); return nil })
if which != "run" {
c.OnStop("stuck", func(context.Context) error { return nil })
c.OnDrain("stuck", func(context.Context) error { <-joined; return nil })
go func() { defer close(joined); <-release; c.Logger.Info("after shutdown deadline") }()
}
if which == "init failure" {
return errors.New("initialization failed")
}
return nil
}
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
close(ready)
if which == "run" {
defer close(joined)
<-release
c.Logger.Info("after shutdown deadline")
} else {
<-ctx.Done()
}
return nil
}
result := make(chan error, 1)
go func() { result <- run(ctx, shortConfig(t, logfile)) }()
if which != "init failure" {
<-ready
cancel()
}
var err error
select {
case err = <-result:
case <-time.After(3 * time.Second):
t.Fatal("shutdown hung")
}
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
t.Fatal(err, closed.Load())
}
close(release)
<-joined
// In the run case the entry's completion notification follows the user defer.
// Close is idempotent and must retain resources even after late completion.
if err = held.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
t.Fatal("retention was lost", err)
}
data, _ := os.ReadFile(logfile)
if !strings.Contains(string(data), "after shutdown deadline") {
t.Fatal("logger closed while work still running", string(data))
}
_ = held.Logger.Close()
})
}
}
func TestExplicitConfigurationAndPreflight(t *testing.T) {
setup(t)
t.Setenv("PLAIN_LEVEL", "debug")
t.Setenv("LOG_LEVEL", "error")
p := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(p, []byte("log:\n level: ${PLAIN_LEVEL}\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(p)
if err != nil || cfg.Log.Level != "debug" {
t.Fatal(cfg.Log.Level, err)
}
if err = os.WriteFile(p, []byte("log:\n level: info\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err = config.Load(p)
if err != nil || cfg.Log.Level != "info" {
t.Fatal("implicit environment override", err)
}
var initCalled atomic.Bool
svc.Initialize = func(context.Context, *svc.ServiceContext) error { initCalled.Store(true); return nil }
if err = os.WriteFile(p, []byte("lifecycle:\n drainTimeout: -1s\n"), 0600); err != nil {
t.Fatal(err)
}
if err = run(context.Background(), p); err == nil || initCalled.Load() {
t.Fatal("invalid configuration initialized dependencies", err)
}
}

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.26.0-preview"
const defaultFrameworkVersion = "v0.28.0-preview"
// This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published.

View File

@@ -238,7 +238,8 @@ func WithLogConfig(settings logx.Config) Option {
}
}
// WithShutdownTimeout controls how long RunContext waits for active requests.
// WithShutdownTimeout controls the graceful HTTP drain budget. After timeout,
// HTTP.ShutdownCancelTimeout separately bounds the wait for canceled handlers.
func WithShutdownTimeout(timeout time.Duration) Option {
return func(cfg *Config) {
if timeout > 0 {

View File

@@ -3,17 +3,28 @@ package config
import (
"log"
"time"
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
)
// HTTPConfig controls built-in HTTP servers. Positive values override the
// selected transport's defaults; zero keeps its existing behavior. Timeouts
// follow that transport's semantics, not a handler execution deadline.
// HTTPConfig controls HTTP serving. Network timeouts follow the selected
// transport's semantics, not a handler execution deadline. Body limits and
// shutdown cancellation grace also apply to App used with an external server.
type HTTPConfig struct {
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"`
ReadTimeout time.Duration `json:"readTimeout"`
WriteTimeout time.Duration `json:"writeTimeout"`
IdleTimeout time.Duration `json:"idleTimeout"`
MaxHeaderBytes int `json:"maxHeaderBytes"`
// DefaultMaxBodyBytes applies to App routes without an explicit limit.
// Zero uses 8 MiB, or the hard limit if smaller. Route limits may override it.
DefaultMaxBodyBytes int64 `json:"defaultMaxBodyBytes"`
// MaxBodyBytes is an optional service hard limit. Zero adds no hard limit
// below the framework's supported maximum of 1 TiB.
MaxBodyBytes int64 `json:"maxBodyBytes"`
// ShutdownCancelTimeout bounds the wait for handlers after cancellation.
// Zero uses 5s. Resources are retained if handlers have not returned.
ShutdownCancelTimeout time.Duration `json:"shutdownCancelTimeout"`
// ErrorLog is a Go-only hook for the nethttp server's internal diagnostics.
ErrorLog *log.Logger `json:"-"`
}
@@ -27,6 +38,7 @@ func (c HTTPConfig) Validate() error {
{"readTimeout", c.ReadTimeout},
{"writeTimeout", c.WriteTimeout},
{"idleTimeout", c.IdleTimeout},
{"shutdownCancelTimeout", c.ShutdownCancelTimeout},
} {
if field.value < 0 {
return &ConfigError{Field: "http." + field.name, Message: "must be nonnegative"}
@@ -35,11 +47,22 @@ func (c HTTPConfig) Validate() error {
if c.MaxHeaderBytes < 0 {
return &ConfigError{Field: "http.maxHeaderBytes", Message: "must be nonnegative"}
}
for _, field := range []struct {
name string
value int64
}{{"defaultMaxBodyBytes", c.DefaultMaxBodyBytes}, {"maxBodyBytes", c.MaxBodyBytes}} {
if field.value < 0 || field.value > httplimits.MaxBodyBytes {
return &ConfigError{Field: "http." + field.name, Message: "must be 0..1 TiB (bytes)"}
}
}
if c.MaxBodyBytes > 0 && c.DefaultMaxBodyBytes > c.MaxBodyBytes {
return &ConfigError{Field: "http.defaultMaxBodyBytes", Message: "must not exceed http.maxBodyBytes"}
}
return nil
}
// WithHTTPConfig sets the built-in server's HTTP options. RunWith uses a
// caller-owned Runner and does not apply these options to that runner.
// WithHTTPConfig sets HTTP options. RunWith leaves network configuration to the
// caller-owned Runner, but App body limits and cancellation grace still apply.
func WithHTTPConfig(settings HTTPConfig) Option {
return func(cfg *Config) { cfg.HTTP = settings }
}

View File

@@ -93,3 +93,40 @@ func TestHTTPInvalidAndUnsupportedSettings(t *testing.T) {
}
}
}
func TestHTTPBodyAndCancellationSettings(t *testing.T) {
for _, cfg := range []config.HTTPConfig{
{DefaultMaxBodyBytes: -1}, {MaxBodyBytes: -1}, {DefaultMaxBodyBytes: 1<<40 + 1}, {MaxBodyBytes: 1<<40 + 1},
{DefaultMaxBodyBytes: 1024, MaxBodyBytes: 512}, {ShutdownCancelTimeout: -time.Second},
} {
if err := cfg.Validate(); err == nil {
t.Fatalf("invalid HTTP config accepted: %+v", cfg)
}
}
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
cfg := config.Default()
cfg.Transport = transport
cfg.TLS = config.TLSConfig{CertFile: "c", KeyFile: "k"}
cfg.HTTP = config.HTTPConfig{DefaultMaxBodyBytes: 1 << 20, MaxBodyBytes: 16 << 20, ShutdownCancelTimeout: time.Second}
if err := cfg.ValidateTransport(); err != nil {
t.Fatal(err)
}
}
t.Setenv("OMNI_BODY_HARD", "16777216")
for _, file := range []struct{ name, body string }{
{"config.json", `{"http":{"defaultMaxBodyBytes":1048576,"maxBodyBytes":"${OMNI_BODY_HARD}","shutdownCancelTimeout":"250ms"}}`},
{"config.yaml", "http:\n defaultMaxBodyBytes: 1048576\n maxBodyBytes: ${OMNI_BODY_HARD}\n shutdownCancelTimeout: 250ms\n"},
} {
path := filepath.Join(t.TempDir(), file.name)
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatal(err)
}
if cfg.HTTP.DefaultMaxBodyBytes != 1<<20 || cfg.HTTP.MaxBodyBytes != 16<<20 || cfg.HTTP.ShutdownCancelTimeout != 250*time.Millisecond {
t.Fatalf("loaded %+v", cfg.HTTP)
}
}
}

View File

@@ -0,0 +1,15 @@
# HTTP 限额与停机
从仓库根目录运行 `go run ./examples/httpSafety`,可用 `-addr 127.0.0.1:18080` 指定端口。仅使用本机 HTTP 和模拟收尾,不连接数据库或其他服务。
上传验证:
```sh
dd if=/dev/zero bs=1048576 count=9 2>/dev/null | curl --data-binary @- http://127.0.0.1:8080/upload
```
普通路由默认 1 MiB,上传路由允许 16 MiB,服务硬上限 20 MiB;以上请求返回已读取的 9437184 字节。将上传路由改为 21 MiB 后,启动会报告该路由与服务硬上限冲突。实际文件上传须额外预留 multipart 字段/边界开销,并配置文件大小限制。
停机验证:另一个终端执行 `curl http://127.0.0.1:8080/work`,随后在服务终端按 Ctrl-C。先等待 1s 排空,再取消请求,处理器模拟 100ms 收尾;日志中先出现 `request cleanup completed`,再出现资源关闭记录。因为用尽排空预算,本次退出仍返回超时错误。
若处理器不在取消收尾窗口内退出,App 和 `deps.Finish(runErr)` 返回 `lifecycle.ErrResourcesRetained` 并跳过资源关闭。真实项目拥有的日志也必须在该错误下保留,生成入口已处理。进程应非零退出;库不自行退出进程,也不强杀 goroutine。

View File

@@ -0,0 +1,62 @@
// This example uses only local HTTP and simulated work; no external services.
package main
import (
"context"
"errors"
"flag"
"io"
"log"
"os"
"os/signal"
"syscall"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func main() {
address := flag.String("addr", "127.0.0.1:8080", "local listen address")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, *address); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run(ctx context.Context, address string) (runErr error) {
deps := lifecycle.NewRuntime(ctx)
deps.OnClose(func() error { log.Print("shared resource closed after requests completed"); return nil })
defer func() { runErr = deps.Finish(runErr) }()
app := omni.New(omni.WithHTTPConfig(omni.HTTPConfig{
DefaultMaxBodyBytes: 1 << 20,
MaxBodyBytes: 20 << 20,
ShutdownCancelTimeout: time.Second,
}), omni.WithShutdownTimeout(time.Second))
defer func() { runErr = errors.Join(runErr, app.Close()) }()
app.HandleWithBodyLimit("POST", "/upload", 16<<20, nil, func(c *omni.Context) error {
n, err := io.Copy(io.Discard, c.Request().Body)
if err != nil {
return err
}
return c.OK(map[string]int64{"bytes": n})
})
app.GET("/work", func(c *omni.Context) error {
log.Print("request accepted")
select {
case <-time.After(10 * time.Second):
return c.OK("finished")
case <-c.Context().Done():
// Simulate bounded rollback/cleanup after cancellation. Dependencies
// remain available until this handler and its defers have returned.
time.Sleep(100 * time.Millisecond)
log.Print("request cleanup completed")
return c.Context().Err()
}
})
log.Printf("listening on %s; POST /upload or GET /work, then Ctrl-C", address)
return app.RunContext(deps.Context(), address)
}

View File

@@ -22,7 +22,7 @@
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
"main.go": "10cb6a84b2eb1b68dc10c2dca949c9dd7c68a9f78a8e6d8f56ce9336d4b2d125"
"main.go": "e1cdd80f8ee090f1391cdfa799762c99b10d4e09715e9d4faefb2cb0291f1c05"
},
"contract": {
"routes": {

View File

@@ -2,7 +2,7 @@ module example.com/managed
go 1.22.0
require git.yuntuo.hk/k2safe/go-omni v0.26.0-preview
require git.yuntuo.hk/k2safe/go-omni v0.27.0-preview
require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect

View File

@@ -41,7 +41,7 @@ func run(ctx context.Context, path string) (runErr error) {
if err != nil {
return err
}
// Unjoined background work may still log. The owning process exits on this
// Unjoined HTTP or background work may still log. The owning process exits on this
// error; do not close its borrowed logger before that work has finished.
defer func() {
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
@@ -52,8 +52,8 @@ func run(ctx context.Context, path string) (runErr error) {
if err != nil {
return err
}
// RunContext drains HTTP requests before returning. This scope owns dependencies
// and closes them exactly once, before closing the borrowed logger.
// Finish propagates incomplete HTTP draining: stop background work but retain
// shared dependencies when any handler is still running. Otherwise close once.
defer func() { runErr = deps.Finish(runErr) }()
app, err := handler.NewApp(deps)
if err != nil {

6
examples/program/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/program
/var/
.env
.env.*
!.env.example
/.goOmni/project.lock

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"kind": "project",
"templateVersion": "v0.28.0-preview",
"module": "example.com/program",
"generated": {
"internal/config/loadGen.go": "8a1a2746f46596a7893832ca411bd7e2d23978661943d322ee732d2c095cbe47",
"internal/svc/runtimeGen.go": "0eb681169112302add633cc7355fbd19844b01fa0b38b94625ec18127e5fb937",
"main.go": "36168d3e3fe822f74cede8eac3fffe5502174899e242c41526f6ad5cab7af4fd"
}
}

View File

@@ -0,0 +1,18 @@
# 通用程序示例
由 `go-omni project new` 生成,入口、加载和装配文件未经修改。项目仅在配置添加 `once`,在 `internal/run/run.go` 决定完成即退出或等待信号;无端口、数据库、队列和业务任务。
```sh
cd examples/program
go mod tidy
go run . -check-config
go run . # once: true,完成即退出
# 编辑 etc/config.yaml 为 once: false 后运行,用 Ctrl+C / SIGTERM 停止。
go build -o program .
go-omni project check
go test ./...
```
使用同版远程 v0.28.0-preview CLI 与框架,无本地 replace。源码发布前开发可临时 `go mod edit -replace git.yuntuo.hk/k2safe/go-omni=../..`,验证后移除此本地替换。
配置、svc、Run 归项目维护;main/Gen.go 和清单由生成器维护。背景组件在 Init 登记停止与排空,已接受工作使用 WorkContext;未结束时不会提前释放共享依赖。详细规则见 [PROGRAM.md](../../PROGRAM.md)。

View File

@@ -0,0 +1,12 @@
# Project-owned. Environment references are explicit: ${NAME} or ${NAME:-fallback}.
log:
level: info
format: json
output: stderr
lifecycle:
stopTimeout: 5s
drainTimeout: 10s
cancelTimeout: 5s
# true: finite run; false: wait for SIGINT/SIGTERM.
once: true

5
examples/program/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module example.com/program
go 1.22.0
require git.yuntuo.hk/k2safe/go-omni v0.28.0-preview

View File

@@ -0,0 +1,29 @@
package config
import (
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
// Config, Default and Validate are project-owned. Add project settings here.
type Config struct {
Once bool `json:"once"`
Log logx.Config `json:"log"`
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
}
func Default() Config {
log := logx.Default()
log.Service = "example.com/program"
return Config{Once: true, Log: log, Lifecycle: lifecycle.DefaultShutdownConfig()}
}
func (c Config) Validate() error {
if err := c.Log.Validate(); err != nil {
return omniConfig.AtField("log", err)
}
if err := c.Lifecycle.Validate(); err != nil {
return omniConfig.AtField("lifecycle", err)
}
return nil
}

View File

@@ -0,0 +1,12 @@
// Code generated by go-omni project. DO NOT EDIT.
package config
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
func Load(path string) (Config, error) {
loader, err := NewLoader()
if err != nil {
return Config{}, err
}
return omniConfig.LoadProject(path, Default(), loader)
}

View File

@@ -0,0 +1,6 @@
package config
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
// NewLoader is project-owned. Register additional decoders here if needed.
func NewLoader() (*omniConfig.Loader, error) { return omniConfig.NewLoader() }

View File

@@ -0,0 +1,19 @@
package run
import (
"context"
"example.com/program/internal/svc"
)
// Run is project-owned. Return nil for normal completion, or an error to fail.
// The starter waits for shutdown; a one-shot program can simply return when done.
// Register background stop/drain hooks in svc.Init, not concurrently here.
// Accepted background work uses deps.WorkContext(), not the early-stop ctx.
func Run(ctx context.Context, deps *svc.ServiceContext) error {
if deps.Config.Once {
deps.Logger.InfoContext(ctx, "program.completed")
return nil
}
<-ctx.Done()
return nil
}

View File

@@ -0,0 +1,44 @@
// Code generated by go-omni project. DO NOT EDIT.
package svc
import (
"context"
"errors"
"example.com/program/internal/config"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func New(ctx context.Context, logger *logx.Logger, cfg config.Config) (result *ServiceContext, err error) {
if ctx == nil {
ctx = context.Background()
}
if err = ctx.Err(); err != nil {
return nil, errors.Join(err, lifecycle.Cause(ctx))
}
if logger == nil {
return nil, errors.New("service context requires a logger")
}
if err = cfg.Validate(); err != nil {
return nil, err
}
runtime, err := lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx, logger), cfg.Lifecycle)
if err != nil {
return nil, err
}
c := &ServiceContext{Runtime: runtime, Config: cfg, Logger: logger}
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("dependency initialization panic: %v", p)
}
if err != nil {
result = nil
err = c.Finish(err)
}
}()
if err = c.Init(c.Context()); err != nil {
return nil, err
}
return c, nil
}

View File

@@ -0,0 +1,24 @@
package svc
import (
"context"
"example.com/program/internal/config"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
// ServiceContext is project-owned. Add dependencies without global variables.
// Runtime owns registered resources; Logger is borrowed from the entry point.
type ServiceContext struct {
*lifecycle.Runtime
Config config.Config
Logger *logx.Logger
}
// Init runs before Run. Register resources immediately with OnClose.
// Register paired OnStop/OnDrain hooks before exposing background work.
// Use WorkContext for accepted work; Context is the early stop notification.
// OnDrain returns nil only after processing and final cleanup have completed.
// Complete all registrations here, before Run can race with shutdown.
// The drain name "go-omni/project/run" is reserved for the generated entry.
func (c *ServiceContext) Init(ctx context.Context) error { return nil }

89
examples/program/main.go Normal file
View File

@@ -0,0 +1,89 @@
// Code generated by go-omni project. DO NOT EDIT.
package main
import (
"context"
"errors"
"example.com/program/internal/config"
program "example.com/program/internal/run"
"example.com/program/internal/svc"
"flag"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx"
"log"
"os"
"os/signal"
"syscall"
)
func main() {
path := flag.String("config", "etc/config.yaml", "configuration file; empty uses Default()")
check := flag.Bool("check-config", false, "validate configuration without initializing dependencies")
flag.Parse()
if *check {
if _, err := config.Load(*path); err != nil {
log.Fatal(err)
}
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, *path); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context, path string) (runErr error) {
cfg, err := config.Load(path)
if err != nil {
return err
}
logger, err := logx.New(cfg.Log)
if err != nil {
return err
}
var deps *svc.ServiceContext
defer func() {
if p := recover(); p != nil {
runErr = fmt.Errorf("program initialization panic: %v", p)
}
if deps != nil {
runErr = deps.Finish(runErr)
}
// Unjoined work may still use its dependencies and logger. The process exits
// unsuccessfully; resource retention never means the work completed.
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
runErr = errors.Join(runErr, logger.Close())
}
}()
deps, err = svc.New(ctx, logger, cfg)
if err != nil {
return err
}
if deps.Context().Err() != nil {
return nil
}
done := make(chan struct{})
// This reserved drain proves Run has actually returned, including its defers.
// Runtime bounds the wait even when Run ignores cancellation.
deps.OnDrain("go-omni/project/run", func(context.Context) error { <-done; return nil })
logger.InfoContext(deps.Context(), "program.starting")
go func() {
var result error
returned := false
defer func() {
if p := recover(); p != nil {
result = fmt.Errorf("program run panic: %v", p)
} else if !returned {
result = errors.New("program run exited without returning")
}
deps.RequestShutdown(result)
close(done)
}()
result = program.Run(deps.Context(), deps)
returned = true
}()
<-deps.Context().Done()
return nil
}

View File

@@ -3,7 +3,7 @@ module example.com/userService
go 1.22.0
require (
git.yuntuo.hk/k2safe/go-omni v0.26.0-preview
git.yuntuo.hk/k2safe/go-omni v0.27.0-preview
github.com/go-sql-driver/mysql v1.9.3
)

View File

@@ -7,6 +7,7 @@ import (
"net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
"git.yuntuo.hk/k2safe/go-omni/validate"
@@ -284,7 +285,12 @@ func (c *Context) WriteError(err error) {
if errors.As(err, &bodyErr) {
err = bodyTooLarge()
}
var original *apierrors.APIError
tooLarge := errors.As(err, &original) && original.Status == http.StatusRequestEntityTooLarge
apiErr := c.ResolveError(err)
if tooLarge || apiErr.Status == http.StatusRequestEntityTooLarge {
httplimits.RejectBody(c.Raw())
}
c.failure = apiErr
if writeErr := c.writeResult(response.Result{
BusinessCode: apiErr.BusinessCode,

View File

@@ -0,0 +1,25 @@
// Package httplimits shares the application body policy across transports.
package httplimits
import "net/http"
const (
DefaultBodyBytes int64 = 8 << 20
MaxBodyBytes int64 = 1 << 40
)
// RejectBody tells cooperating transports not to drain a rejected body. The
// semantic failure must survive encoders that map business failures to HTTP 200.
func RejectBody(w http.ResponseWriter) {
for {
if reject, ok := w.(interface{ RequestBodyTooLarge() }); ok {
reject.RequestBodyTooLarge()
return
}
unwrap, ok := w.(interface{ Unwrap() http.ResponseWriter })
if !ok {
return
}
w = unwrap.Unwrap()
}
}

View File

@@ -35,14 +35,16 @@ c.OnDrain("jobs", jobs.Wait)
```yaml
server:
shutdownTimeout: 10s # HTTP 排空时限,保持原语义
shutdownTimeout: 10s # HTTP 排空时限
http:
shutdownCancelTimeout: 5s # HTTP 取消后的收尾窗口
lifecycle:
stopTimeout: 5s
drainTimeout: 10s
cancelTimeout: 5s
```
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。可用 `GO_OMNI_LIFECYCLE_STOP_TIMEOUT`、`GO_OMNI_LIFECYCLE_DRAIN_TIMEOUT`、`GO_OMNI_LIFECYCLE_CANCEL_TIMEOUT` 覆盖;生成配置还支持完整的 `GO_OMNI_SERVER_LIFECYCLE_*` 路径,别名优先。配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。环境变量仅通过配置文件显式引用,例如 `cancelTimeout: "${WORK_CANCEL_TIMEOUT:-5s}"`,不存在自动覆盖或别名优先。后台生命周期配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
阶段超时会取消阶段 context 和 WorkContext,再给尚未返回的钩子一个 cancelTimeout 收尾窗口。若所有钩子都返回 nil,可以释放依赖,但仍返回超时错误,进程非零退出。task.Wait 收到取消会取消其任务并返回错误;这不证明全部任务已退出,因此仍按以下保守规则处理。
@@ -50,10 +52,24 @@ Stop 超时且仍未返回的组件,不会再并发调用它同名的 Drain;
钩子返回错误、panic、缺少匹配 Drain,或窗口结束后仍有钩子未返回时,Runtime 返回可由 `errors.Is(err, lifecycle.ErrResourcesRetained)` 判断的错误,**跳过全部 OnClose**;生成入口也保留借用的日志实例并非零退出,由进程退出释放剩余 OS 资源。不会悄悄重试 Close,也不会让晚到的后台结果触发自动关闭。错误链保留初始化/监听/后台故障及具体 HookError 的阶段、名称和原始原因。
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;本版阶段时限不会强制中断资源 Close。HTTP 超时沿用现有传输层规则,业务处理器仍须响应请求取消。
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;阶段时限不会强制中断资源 Close。HTTP 超时也必须经过下述完成屏障,业务处理器仍须响应请求取消。
手写项目使用 `lifecycle.NewRuntimeWithConfig(ctx, cfg)`;完成 HTTP 排空后调用 `runtime.Finish(runErr)`。库本身不执行 os.Exit。
### HTTP 请求与依赖的关闭屏障
App 统计已进入且尚未退出的整个处理链,包括中间件、响应编码和 defer;停止接收与登记请求共用同步屏障。关闭连接、取消 context、HTTP/2 session 结束均不等于处理链结束。
`RunContext` / `ServeContext` 先按 `shutdownTimeout` 排空,超时取消并关闭连接;App 随后按 `http.shutdownCancelTimeout`(零默认 5s)等待处理器完成收尾。收尾成功则执行逆序 OnShutdown,仍保留原排空超时错误。收尾窗口结束时还有处理器运行,则永久返回 `ErrResourcesRetained` 并跳过所有 OnShutdown;后续 Close 或晚到的处理器返回不会触发补做清理。
`Runtime.Finish(runErr)` 收到该错误后,继续执行后台 OnStop/OnDrain 并取消 WorkContext,但跳过 OnClose;后续 `Runtime.Close` 也保持资源保留。生成入口通过这条错误链保留数据库等共享依赖和借用日志,并非零退出。手写入口必须传递 runErr 给 Finish,并在 `errors.Is(runErr, lifecycle.ErrResourcesRetained)` 时跳过自己拥有的额外资源清理,不能无条件 defer DB.Close/Logger.Close。
`App.Close` 本身会停止接收、取消请求并等待收尾,适用于外部 HTTP 宿主;正常优雅排空应先由宿主停止监听并 Shutdown,再调用 Close。不要在请求处理器或清理钩子内部调用 Close(会等待自身)。`App`、`Engine`、`NewServer().Handler` 和 `RunWith` 都经过统计;已停止的 App 返回简单 503,不再调用可能使用已关闭依赖的中间件或编码器。App 不支持关闭后重新启动。
外部 Runner 返回不构成请求完成证明,App 仍执行自己的完成屏障;Runner 负责关闭自己拥有的 listener/连接。原始 `Router().ServeHTTP` 绕过 App。处理器退出后继续运行的自建 goroutine、Hijack/WebSocket 后台工作,必须用 OnStop/OnDrain 注册,框架不会推断这些工作的生命周期。HTTP/2 低层 Shutdown 也会等待独立 handler goroutine;超时返回 error 仍不表示它已退出。
新行为增加一个有限 HTTP 收尾窗口,调用方总停机预算应计入它。已有生成项目升级框架即可获得 Runtime.Finish 的保护;重新生成可同步入口注释。可运行示例见 [httpSafety](../examples/httpSafety/README.md)。
### 升级
v0.14.x 受管理项目将 module 和 CLI 一起升级到 v0.15.0-preview,再运行 `go-omni api generate`、`go-omni api generate --check` 和业务测试。生成器更新 main/runtimeGen,保留 svc、Logic 和配置。已有配置不添加新字段也会使用默认时限,需要调整时再编辑 yaml。
@@ -80,3 +96,7 @@ err := app.RunContext(control.Context())
实际使用时 App 和依赖通常先初始化,再由并发监控回调请求退出。资源在 HTTP 排空完成后由调用方关闭,并读取 `control.Err()` 合并清理期间报告的首个故障。通知本身不会关闭资源、执行 os.Exit 或发送操作系统信号。
`RequestShutdown(nil)` 表示正常停止,非空 error 表示故障。Controller 保留首个非空故障,重复调用和并发调用安全。`Cause(ctx)` 将符合 errors.Is(err, context.Canceled) 的取消原因视为正常停止(包含新版 Go 的信号原因),保留其他取消原因和 DeadlineExceeded。显式 RequestShutdown(err) 上报的非空错误始终由 Controller.Err 保留,包括包装 context.Canceled 的错误。App.RunContext、ServeContext、RunWith 均保留这些异常原因,与网络适配器无关。
## 通用工程入口
`go-omni project new` 可生成不监听端口的普通 Go 程序,直接复用 Runtime。入口监督项目 Run 的完成,并与 Init 登记的后台工作一起排空;Run/Init 异常和资源保留信号会传递到退出状态。完整运行契约与配置见 [通用工程指南](../PROGRAM.md)。

View File

@@ -11,10 +11,10 @@ import (
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// ErrResourcesRetained means background quiescence could not be established.
// ErrResourcesRetained means HTTP or background quiescence was not established.
// OnClose callbacks were deliberately skipped. Close is not retried; an owning
// process should exit unsuccessfully instead of continuing with a stopped Runtime.
var ErrResourcesRetained = errors.New("lifecycle: background shutdown incomplete; shared resources retained")
var ErrResourcesRetained = errors.New("lifecycle: shutdown incomplete; shared resources retained")
type ShutdownConfig struct {
StopTimeout time.Duration `json:"stopTimeout"`

View File

@@ -68,6 +68,10 @@ func (r *Runtime) OnClose(cleanup func() error) {
// skips resource callbacks and returns ErrResourcesRetained. Once release is
// safe, all OnClose callbacks are attempted even if one fails or panics.
func (r *Runtime) Close() error {
return r.close(false)
}
func (r *Runtime) close(retain bool) error {
if r == nil {
return nil
}
@@ -108,7 +112,7 @@ func (r *Runtime) Close() error {
drainErr := r.runPhase("drain", eligible, r.shutdown.DrainTimeout)
r.cancelWork()
r.closeErr = errors.Join(stopErr, drainErr)
if unsafePhase(stopErr) || unsafePhase(drainErr) {
if retain || unsafePhase(stopErr) || unsafePhase(drainErr) {
r.closeErr = errors.Join(r.closeErr, ErrResourcesRetained)
return
}
@@ -129,12 +133,14 @@ func runCleanup(fn func() error) (err error) {
}
// Finish closes resources and preserves the operation, cleanup and shutdown
// errors. Read the final cause only after monitors have been stopped by Close.
// errors. An incoming ErrResourcesRetained (for example, unfinished HTTP work)
// still runs stop/drain hooks, but permanently skips resource cleanup, including
// subsequent Close calls. Read the final cause after stopping the monitors.
func (r *Runtime) Finish(err error) error {
if r == nil {
return err
}
err = errors.Join(err, r.Close())
err = errors.Join(err, r.close(errors.Is(err, ErrResourcesRetained)))
if cause := r.Lifecycle.Err(); cause != nil && !errors.Is(err, cause) {
err = errors.Join(err, cause)
}

View File

@@ -54,3 +54,28 @@ func TestRuntimeRetainsMonitorFaultDuringCleanup(t *testing.T) {
}()
r.OnClose(func() error { return nil })
}
func TestFinishRetainsDependenciesAfterIncompleteHTTPDrain(t *testing.T) {
r := NewRuntime(context.Background())
var order []string
r.OnStop("worker", func(context.Context) error { order = append(order, "stop"); return nil })
r.OnDrain("worker", func(context.Context) error { order = append(order, "drain"); return nil })
r.OnClose(func() error { t.Error("released dependency while HTTP work remains"); return nil })
fault := errors.New("HTTP shutdown timeout")
err := r.Finish(errors.Join(fault, ErrResourcesRetained))
if !errors.Is(err, fault) || !errors.Is(err, ErrResourcesRetained) {
t.Fatal(err)
}
if !reflect.DeepEqual(order, []string{"stop", "drain"}) {
t.Fatal(order)
}
if r.WorkContext().Err() == nil {
t.Fatal("background work was not canceled")
}
if err = r.Close(); !errors.Is(err, ErrResourcesRetained) {
t.Fatal("deferred Close bypassed retention", err)
}
if err = r.Finish(nil); !errors.Is(err, ErrResourcesRetained) {
t.Fatal("repeat Finish bypassed retention", err)
}
}

View File

@@ -0,0 +1,43 @@
# HTTP 限额与停机安全验收(2026-09-29)
对应 v0.27.0-preview;历史 v0.26.0-preview 标签保持。框架不包含业务文件规格、数据库实现或应用专属错误码。
## 行为验收
| 场景 | 结果 |
| --- | --- |
| nethttp/native/HTTP2:16 MiB 路由接收 9 MiB | 固定长度与未知长度均成功 |
| 默认、路由覆盖、服务硬上限 | 边界值成功,超限进入 BODY_TOO_LARGE;读取中间件也受 reader 限额约束 |
| 路由超过服务硬上限 | 启动前拒绝,诊断包含嵌套分组完整路径及两级字节数;外部宿主可调用 ValidateHTTP |
| native 提前拒绝上传 | 先发出响应再有界半关闭,避免即时关闭触发 TCP reset;10 轮上传相关 race 回归通过 |
| 错误目录将超限映射为 HTTP 200 | 业务码/HTTP 策略保持,不再继续读取被拒绝的请求体;客户端不发送 body 也能及时收到响应 |
| 正常停机与取消后收尾 | 处理链和 defer 完成后才运行资源清理;收尾成功仍保留原排空超时错误 |
| 不响应取消的处理器 | 有界返回 ErrResourcesRetained,跳过 App 清理;重复 Close、晚到返回不触发清理 |
| App/Engine/NewServer/RunWith | 统一跟踪请求;停止后拒绝新请求,不执行依赖项目资源的处理链 |
| HTTP/2 客户端断开 | Shutdown 同时等待连接和独立 handler,不把连接结束当成业务结束 |
| 真实生成项目 | HTTP 未结束时 Runtime.Finish 仍停止后台工作,但保留客户端与日志;重复 deps.Close 不能绕过保护 |
| 可运行示例 | 9 MiB 上传返回 9437184 字节;SIGTERM 后先请求收尾、再关闭资源;排空超时退出码为 1 |
## 检查
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:54 个测试包通过,包含真实生成消费者的 race/build。
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:54 个测试包通过。随后 native 拒绝连接收尾补充修复,在最终代码上重新执行 `GOTOOLCHAIN=go1.22.12 go test -count=1 ./app ./config ./lifecycle ./httpx ./transport/...`,8 个测试包通过。
- `go test -race ./app ./transport/native -run 'Test(UploadPolicy|ConfiguredBody|ApplicationRejectsUnreadBody)' -count=10`:通过。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
- 编译并实跑 `examples/httpSafety`,使用随机本机端口与实际 SIGTERM 验证上传和关闭顺序,无外部服务。
## 兼容边界
旧 nethttp 手写普通路由新增默认 8 MiB 限额,大请求应显式配置;生成契约默认 1 MiB 保持。取消后的等待默认 5s,需计入服务停机预算。手写资源拥有者须将运行错误传给 Runtime.Finish,并在资源保留信号下跳过自己的额外关闭操作。自建 goroutine、Hijack 后的工作仍须登记后台生命周期。
自定义 Runner、独立 native/http2.Server、反向代理的额外限额由各自拥有者配置;使用 WithTransport 获取统一装配。请求体、multipart 单文件和幂等缓存限额保持各自用途。已发送的流式响应无法撤回,先完成有界读取再执行业务。
## 远程发布验收
空模块缓存安装远程固定版本后,10 组生成消费者和 98 项发布检查通过,耗时 107.57 秒,无本地 replace;覆盖上传限额、取消收尾、HTTP/2 断连、生成项目依赖与日志保留。标签固定在 `3b00bb55d189cbe480c7d15c8d14bcd09d3f2d52`,见 [远程验收记录](httpSafetyRemote.json)。
模块校验和:`h1:IYc1zEBQX6GTPvORAC9Q7ezlzKlGn1nVnK8T68L/DGA=`。运行命令:
```sh
npm exec --yes --package=typescript@5.6.3 -- python3 scripts/verifyManagedRelease.py --version v0.27.0-preview --report reports/httpSafetyRemote.json
```

View File

@@ -0,0 +1,292 @@
{
"passed": true,
"frameworkVersion": "v0.27.0-preview",
"frameworkSum": "h1:IYc1zEBQX6GTPvORAC9Q7ezlzKlGn1nVnK8T68L/DGA=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 107.57,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"remoteTransactionStages",
"remoteTransactionCauseChains",
"remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown",
"remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors",
"remoteTransactionExample",
"remoteI18nRuntimeRace",
"remoteI18nGeneratedConsumer",
"remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback",
"remoteCLDRSamplesAndBrowserConformance",
"remoteTypeScriptConsumer",
"remoteI18nCLIExtractionCheckExportTypes",
"remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention",
"remoteJSONReaderCausePreservation",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "25ef07654abe42fe5f7e9cf5e5aa3bf102b311502bdbf16c0346970f87ee3053",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "4bf8f3cee4d2fc832c344f6fae7446a56fbfd673cb3816feab926e1d39c7eca3",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "6860e3f440c4d4f51a55085e50066b1a43cd31d5068c2f7a01a708c16823aef7",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "5cf05972b56870d89da5290e3b0db777b0253c3b4214cee3d47ab7fa6d76a263",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2",
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
"database/transaction_test.go": "b8fb122c145836c07ca062dc8c89c07ae7482885d33c3f2c239cca21e97b72d4",
"database/transactionResponse_test.go": "bc59b17605c5b5e051fc987caf6dafbbb5d71f4b1380a5ceec8f2dafaafb6af7",
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
"examples/transactions/main.go": "ac2a17556b63c0bbb8a5c726381b6929df235da089549937a70d3696e2cba3f2",
"examples/transactions/demoDriver.go": "950b5f9bf0d20b8b2bc7590d502e110f2815fc394df789cac39e324149870ab6",
"i18n/bundle.go": "cd0b54e779fa9f8e3a1b5d4c7456ae06f0f39618365362de329d4b61104abb93",
"i18n/types.go": "cc912f7c7dc598de758cec79568fd0bdfac6cab8a8333a6ce0570680236d415a",
"i18n/plural_test.go": "ad3a430f370bc51503aa5bce335dcb5962f6e8226f2925956ddd419fd9c230d8",
"i18n/export.go": "99608af1d487fced1bba49b77157e7a1f51afb4341fbb607f09920dfca94155a",
"i18n/pattern.go": "1c7a91e48f449124d2de90abebd698401aef93896fcdfcf24a83e12a048c5192",
"i18n/README.md": "11a933abe712c961ca1c3b138d4440bc440a7b4c6e30654849e604efe4be056c",
"i18n/plural.go": "9b69e23568a4ea240d5d4bd2c62237932245a8a91aa451386f4a7d9d76d774f9",
"i18n/load.go": "6e43d781ba90db6de78d2138e496161c391797a735211cae7342abebac2c5618",
"i18n/bundle_test.go": "f9e30d36955722f51e7d9dfe685515441451459547a3235b97064de7dda4d8f9",
"i18n/i18ntool/extract_test.go": "dd62077d190e4264348f18e8f86d25e324eebf2d471918f27da8f0c552bf46c6",
"i18n/i18ntool/extract.go": "f0ab49435f61750b06ee8105ff016be5fe06c8404f90163708d9f7e423ae89c0",
"i18n/errori18n/errors_test.go": "2c2b74b1752d4367035c9f6b9f0f79bd05dac583f37f4e4de97d2163e667be4d",
"i18n/errori18n/errors.go": "539a9eac6b841a566bc808363e9505226c7510d5dc277da621dbefb16cb06ecd",
"i18n/browser/runtime.d.mts": "fd2f18dbb6d1b1dfe2392a5e0831354f0fb2ac8815328d8b422ab79503889ad7",
"i18n/browser/runtime.test.mjs": "83b219f3b72cdeb417f6223f9f0942ef8d3b43d5051b1c66dc475c7634d65acf",
"i18n/browser/README.md": "8a3de821c6cdf57c99ee2b56fa0deeb7557b9d11a9a77bf05b00c1ae22bf34ab",
"i18n/browser/conformance.mjs": "95b7325ef24513b53eb5b8d3475fe3c5c7c56bc2da2cb1aa74e097b2ee410623",
"i18n/browser/assets.go": "a20c679902c5d96dba13b0df31ef98e3516f0ebdd1c403740a6a720a412b0b84",
"i18n/browser/runtime.mjs": "a38f6378a835661ee9198ff910eafd04f4e56051c113d0637703e59e4ae3cd3c",
"i18n/browser/testdata/consumer.mts": "84e3e26d2d86a03b30e3fcd27c3bb51c268ac89ef115f8def274321250b46e08",
"i18n/cldr/LICENSE": "220ba0e1c43b99530d2d5bdb892a99dca0989414f51ab695ecd90163eaa1ec3b",
"i18n/cldr/README.md": "cd5aef06e415cf21913a5b8736c29abbc02df449868c16cd08be8aa7e0c8fea0",
"i18n/cldr/plurals.json": "ce19668400e9f7904e8b3939d1f0a2872fe8da7bdea9f45f5d50b3a2bac3b382",
"i18n/httpi18n/http_test.go": "1f0266d2d2a54bfe767efe76bfedce6130f6c988dd04be801416336a95a5d606",
"i18n/httpi18n/http.go": "ac22eeb795b62b74d0b9a233b6fb79a337f35d430e6a136fb31f643ee4512f0d",
"examples/i18n/index.html": "7d0c9e1fdaf96c883cbe8e671ae094d83c2742a9f76b820649d58d62f4944c12",
"examples/i18n/config.json": "c3e12efcedbfc6a8ca73cae9cd4cc3617ee363af8747aec630808e6f5e1eb682",
"examples/i18n/README.md": "bf5c3ae49b921b1c25d06e19d5ecb743f349ea9d085b9d9da79f1391a29fa9e1",
"examples/i18n/main.go": "3f8599781e423a9e3a968f5310fcc80d2daf5f09b733050c1e13c4fea589f29e",
"examples/i18n/main_test.go": "c99f736841fe579d2134762d4962da73d6ab88090e5921ce8c26edf5134bee9b",
"examples/i18n/locales/ui.en.json": "fa8b210ad1a78baae8f444420c983c5498025a1a3ea0bd15d64d8125629bee42",
"examples/i18n/locales/ui.zh-Hans.json": "04761003bea2a02b1e746a1f458f50fa2e0fdb3706d02bc5cb5a4f16378c5021",
"examples/i18n/locales/errors.zh-Hans.json": "d9113b720f4c284b90c41a6db4a867d272db4e951cd284eeb00ca69e3fc21385",
"app/i18n_test.go": "4d6125b78c33c7df759584b84f791d9a041673d6536503c6671a71e20b588d39",
"errors/localization.go": "a047c33e9c4ad9ca10aacfa0800dec51a5db788c1299fba0fb9b361abef41c85",
"cmd/go-omni/i18n.go": "be95a89de3fcc34baaea76669f30616902b0f5d7d6ef018e9c29a8e38106fb6a",
"cmd/go-omni/apigen/i18nVersion.go": "c4187e5d77164b8e906294b63e8d351db49a6d587b4f77a32529f6a4b75cb1c9",
"cmd/go-omni/apigen/testdata/i18n/consumer_test.go": "b62ad30f5912c50e1f6bb1737fd75430ead113989990acb6dcdc93f5d88de0af",
"contract/policy_test.go": "d1ea81c923dda58ae75323cc1eb9773517e63180153dbdf19eb35a634c9890db",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15"
}
}

View File

@@ -66,10 +66,23 @@ npm exec --yes --package typescript@5.6.3 -- go test ./i18n \
- `go vet ./...`、`go build ./...`、浏览器运行时测试、发布脚本语法检查、`git diff --check`:通过。
- `python3 scripts/verifyManagedProject.py --report reports/i18nManagedLocal.json`:真实生成项目运行、构建、重复生成、nethttp/native 信号退出、上传下载与故障清理通过;临时进程已清理。[机器记录](i18nManagedLocal.json)。
完整最终输出见 [i18nReleaseValidation.txt](i18nReleaseValidation.txt)。发布后再从空 module 缓存安装固定标签,验证没有本地 replace;远程报告独立提交,不移动发布标签。
完整最终输出见 [i18nReleaseValidation.txt](i18nReleaseValidation.txt)。发布后从空 module 缓存安装固定标签的验收也已通过,见文末;远程报告独立提交,不移动发布标签。
## 性能与发布边界
`BenchmarkRender` 对俄文基数复数、22 参数的单条消息,当前机器一次测量为 3,753 ns/op、1,040 B/op、27 allocs/op;这只是开发机样本,不代表 SLA 或与旧实现的性能对比。
本次按 Go module 标签 `v0.26.0-preview` 发布;浏览器运行时随 module 分发,没有单独发布 npm 包。CLDR 46.1.0 数据随源码和导出包携带许可,启动不下载规则。v1 支持普通文本、命名参数、select 和基数复数;不是完整 ICU MessageFormat,不自动处理金额、日期、时区、RTL 布局或业务字段翻译。
## 已发布远程产物验收
`v0.26.0-preview` 标签固定在 `d1a747c36f15d964dd445d661623b68a45892adf`;远程 Go module 校验和为 `h1:9Ao/6+8jdqXf4Ia++rhgorTrYclAgrWgXw3U+AWtB4I=`。发布代码与标签已推送,验收记录独立提交,标签没有移动。
使用空 module 缓存安装已发布 CLI,10 组真实生成消费者、87 项发布检查通过,耗时 83.76 秒。CLI 与框架固定同版,所有消费者均确认无本地 replace。新检查覆盖下载模块中的独立 i18n、HTTP 错误不变量、源语言仅回退、CLDR 及 Go/浏览器一致性、TypeScript strict 消费者、四个 CLI 子命令和浏览器切换竞态/失败保留。详见 [i18nRemote.json](i18nRemote.json)。
复验命令(需要 Node.js,TypeScript 由 npm 临时缓存提供):
```sh
npm exec --yes --package typescript@5.6.3 -- python3 scripts/verifyManagedRelease.py \
--version v0.26.0-preview --report reports/i18nRemote.json
```

267
reports/i18nRemote.json Normal file
View File

@@ -0,0 +1,267 @@
{
"passed": true,
"frameworkVersion": "v0.26.0-preview",
"frameworkSum": "h1:9Ao/6+8jdqXf4Ia++rhgorTrYclAgrWgXw3U+AWtB4I=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 83.76,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteTransactionStages",
"remoteTransactionCauseChains",
"remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown",
"remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors",
"remoteTransactionExample",
"remoteI18nRuntimeRace",
"remoteI18nGeneratedConsumer",
"remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback",
"remoteCLDRSamplesAndBrowserConformance",
"remoteTypeScriptConsumer",
"remoteI18nCLIExtractionCheckExportTypes",
"remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention",
"remoteJSONReaderCausePreservation",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "25ef07654abe42fe5f7e9cf5e5aa3bf102b311502bdbf16c0346970f87ee3053",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "2305b168701c4564f83d680fc62e6f5a3589f4a3d7e78e66d82a6328bf0a6a89",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"scripts/verifyManagedRelease.py": "91b87f1688a11b91f7462ce860e06a2ac929db2ba14f381f7aecefddadac0cea",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "f8007526a9ebbbc781b2e27e37a49ec768deb568d56846a6ccc62b059a012fd0",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "6fbeac9c21f5dedad05471deb03a6b4465a0cb10f252d651da2f6f6cb3841998",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "5cf05972b56870d89da5290e3b0db777b0253c3b4214cee3d47ab7fa6d76a263",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2",
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
"database/transaction_test.go": "b8fb122c145836c07ca062dc8c89c07ae7482885d33c3f2c239cca21e97b72d4",
"database/transactionResponse_test.go": "bc59b17605c5b5e051fc987caf6dafbbb5d71f4b1380a5ceec8f2dafaafb6af7",
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
"examples/transactions/main.go": "ac2a17556b63c0bbb8a5c726381b6929df235da089549937a70d3696e2cba3f2",
"examples/transactions/demoDriver.go": "950b5f9bf0d20b8b2bc7590d502e110f2815fc394df789cac39e324149870ab6",
"i18n/bundle.go": "cd0b54e779fa9f8e3a1b5d4c7456ae06f0f39618365362de329d4b61104abb93",
"i18n/types.go": "cc912f7c7dc598de758cec79568fd0bdfac6cab8a8333a6ce0570680236d415a",
"i18n/plural_test.go": "ad3a430f370bc51503aa5bce335dcb5962f6e8226f2925956ddd419fd9c230d8",
"i18n/export.go": "99608af1d487fced1bba49b77157e7a1f51afb4341fbb607f09920dfca94155a",
"i18n/pattern.go": "1c7a91e48f449124d2de90abebd698401aef93896fcdfcf24a83e12a048c5192",
"i18n/README.md": "11a933abe712c961ca1c3b138d4440bc440a7b4c6e30654849e604efe4be056c",
"i18n/plural.go": "9b69e23568a4ea240d5d4bd2c62237932245a8a91aa451386f4a7d9d76d774f9",
"i18n/load.go": "6e43d781ba90db6de78d2138e496161c391797a735211cae7342abebac2c5618",
"i18n/bundle_test.go": "f9e30d36955722f51e7d9dfe685515441451459547a3235b97064de7dda4d8f9",
"i18n/i18ntool/extract_test.go": "dd62077d190e4264348f18e8f86d25e324eebf2d471918f27da8f0c552bf46c6",
"i18n/i18ntool/extract.go": "f0ab49435f61750b06ee8105ff016be5fe06c8404f90163708d9f7e423ae89c0",
"i18n/errori18n/errors_test.go": "2c2b74b1752d4367035c9f6b9f0f79bd05dac583f37f4e4de97d2163e667be4d",
"i18n/errori18n/errors.go": "539a9eac6b841a566bc808363e9505226c7510d5dc277da621dbefb16cb06ecd",
"i18n/browser/runtime.d.mts": "fd2f18dbb6d1b1dfe2392a5e0831354f0fb2ac8815328d8b422ab79503889ad7",
"i18n/browser/runtime.test.mjs": "83b219f3b72cdeb417f6223f9f0942ef8d3b43d5051b1c66dc475c7634d65acf",
"i18n/browser/README.md": "8a3de821c6cdf57c99ee2b56fa0deeb7557b9d11a9a77bf05b00c1ae22bf34ab",
"i18n/browser/conformance.mjs": "95b7325ef24513b53eb5b8d3475fe3c5c7c56bc2da2cb1aa74e097b2ee410623",
"i18n/browser/assets.go": "a20c679902c5d96dba13b0df31ef98e3516f0ebdd1c403740a6a720a412b0b84",
"i18n/browser/runtime.mjs": "a38f6378a835661ee9198ff910eafd04f4e56051c113d0637703e59e4ae3cd3c",
"i18n/browser/testdata/consumer.mts": "84e3e26d2d86a03b30e3fcd27c3bb51c268ac89ef115f8def274321250b46e08",
"i18n/cldr/LICENSE": "220ba0e1c43b99530d2d5bdb892a99dca0989414f51ab695ecd90163eaa1ec3b",
"i18n/cldr/README.md": "cd5aef06e415cf21913a5b8736c29abbc02df449868c16cd08be8aa7e0c8fea0",
"i18n/cldr/plurals.json": "ce19668400e9f7904e8b3939d1f0a2872fe8da7bdea9f45f5d50b3a2bac3b382",
"i18n/httpi18n/http_test.go": "1f0266d2d2a54bfe767efe76bfedce6130f6c988dd04be801416336a95a5d606",
"i18n/httpi18n/http.go": "ac22eeb795b62b74d0b9a233b6fb79a337f35d430e6a136fb31f643ee4512f0d",
"examples/i18n/index.html": "7d0c9e1fdaf96c883cbe8e671ae094d83c2742a9f76b820649d58d62f4944c12",
"examples/i18n/config.json": "c3e12efcedbfc6a8ca73cae9cd4cc3617ee363af8747aec630808e6f5e1eb682",
"examples/i18n/README.md": "bf5c3ae49b921b1c25d06e19d5ecb743f349ea9d085b9d9da79f1391a29fa9e1",
"examples/i18n/main.go": "3f8599781e423a9e3a968f5310fcc80d2daf5f09b733050c1e13c4fea589f29e",
"examples/i18n/main_test.go": "c99f736841fe579d2134762d4962da73d6ab88090e5921ce8c26edf5134bee9b",
"examples/i18n/locales/ui.en.json": "fa8b210ad1a78baae8f444420c983c5498025a1a3ea0bd15d64d8125629bee42",
"examples/i18n/locales/ui.zh-Hans.json": "04761003bea2a02b1e746a1f458f50fa2e0fdb3706d02bc5cb5a4f16378c5021",
"examples/i18n/locales/errors.zh-Hans.json": "d9113b720f4c284b90c41a6db4a867d272db4e951cd284eeb00ca69e3fc21385",
"app/i18n_test.go": "4d6125b78c33c7df759584b84f791d9a041673d6536503c6671a71e20b588d39",
"errors/localization.go": "a047c33e9c4ad9ca10aacfa0800dec51a5db788c1299fba0fb9b361abef41c85",
"cmd/go-omni/i18n.go": "be95a89de3fcc34baaea76669f30616902b0f5d7d6ef018e9c29a8e38106fb6a",
"cmd/go-omni/apigen/i18nVersion.go": "c4187e5d77164b8e906294b63e8d351db49a6d587b4f77a32529f6a4b75cb1c9",
"cmd/go-omni/apigen/testdata/i18n/consumer_test.go": "b62ad30f5912c50e1f6bb1737fd75430ead113989990acb6dcdc93f5d88de0af",
"contract/policy_test.go": "d1ea81c923dda58ae75323cc1eb9773517e63180153dbdf19eb35a634c9890db"
}
}

View File

@@ -0,0 +1,35 @@
# v0.28 通用工程验收
本轮新增 `project new/generate/check`,默认生成普通 Go 程序,不包含 API、网络监听、外部服务初始化或业务任务。项目维护配置、svc 和 Run;模板维护入口、加载与生命周期装配。
## 发布前回归
2026-09-29,macOS arm64,Go 1.27.1 / Go 1.22.12:
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:56 个测试包通过,含现有 API 生成消费者与新通用工程消费者。
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:56 个测试包通过。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
- 最终补充真实进程异常退出断言后,新生成器再次通过 race 集成回归与 Go 1.22 集成回归。
- `examples/program` 在临时副本中使用显式本地替换,完成 tidy、race、配置检查、实际一次性运行、生成漂移检查;仓库示例不含本地 replace。
## 验证边界
生成保护覆盖:手写文件逐字节保留、修改生成文件报冲突、缺失生成文件补回、缺失手写文件显式报错、只读 check/预览、模板升级漂移、工程与模块身份、最低依赖版本、路径/符号链接和锁保护、写入冲突后的事务回滚。
实际生成的消费者保留全部入口/加载/装配代码,只修改项目所有文件,覆盖:
- 一次性正常完成,主流程 defer 结束后反序关闭资源。
- Init 返回错误或 panic,Run 返回错误、panic、Goexit,以及组件报告致命故障。
- 初始化到一半失败后停止并等待已经启动的工作。
- 停止接收后等待处理与确认完成,期间 WorkContext 仍可用。
- 排空超时后请求取消,宽限期内完成则可清理;仍未结束则保留共享资源和日志。
- 主运行入口、后台工作、失败初始化后的工作不响应取消,均不会提前关闭资源。
- 显式环境引用、固定值不被隐式覆盖、非法配置阻止初始化,check-config 不打开日志文件。
- 真实构建的进程:SIGINT/SIGTERM 正常退出码 0;Run 错误/panic、主流程不响应停止导致超时,退出码 1。
- 默认进程通过本机 lsof 检查,无 TCP/UDP 端口;生成目录和源码无 API/Handler/Swagger/数据库/队列启动。
文件替换只对单文件原子,普通失败会回滚,整个工程不具备崩溃事务性。未登记的 goroutine、不遵守完成契约的项目组件、SIGKILL 和进程崩溃无法由模板保证收尾。新模板不改变既有调度器或队列的重试/投递语义。
## 远程发布验收
发布后运行 `scripts/verifyProgramRelease.py` 与原有 `scripts/verifyManagedRelease.py`,使用空模块缓存安装固定远程 CLI,禁止本地 replace。结果在完成后补入本文件及 JSON 报告。

View File

@@ -134,6 +134,7 @@ def main():
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence)
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
checks += ["generatedIncompleteHTTPDrainRetainsDependenciesAndLogger"]
# Run tests from the downloaded module, not the framework checkout.
http_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/httpc"], starter, env)
@@ -152,7 +153,8 @@ def main():
config_passed = {e.get("Test") for e in config_events if e.get("Action") == "pass"}
assert {"TestExplicitReferencesAcrossFormats", "TestReferenceFallbackAndFixedValues",
"TestReferenceDiagnosticsAndScalarLimits", "TestRemovedEnvTagsAndFieldSuggestion",
"TestModuleFieldDiagnostics", "TestModuleDiagnosticsKeepReferenceAndCause"} <= config_passed
"TestModuleFieldDiagnostics", "TestModuleDiagnosticsKeepReferenceAndCause",
"TestHTTPBodyAndCancellationSettings"} <= config_passed
run([cli, "api", "generate", "--check"], starter, env)
run(["go", "run", ".", "-config", "etc/config.yaml", "-check-config"], starter, env)
checks += ["remoteExplicitConfigurationRaceTests", "literalAndRequiredDefaultReferences",
@@ -162,7 +164,12 @@ def main():
"copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"},
"logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"},
"cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"},
"app": {"TestAppCORSRoutingAndErrors", "TestAppCORSConfigIsolation", "TestCORSTransports", "TestI18nRouteScopesEarlyFailuresAndReplay", "TestI18nExplicitProtocolsAndConcurrentApps", "TestI18nAdapterFailurePreservesBusiness"},
"app": {"TestAppCORSRoutingAndErrors", "TestAppCORSConfigIsolation", "TestCORSTransports", "TestI18nRouteScopesEarlyFailuresAndReplay", "TestI18nExplicitProtocolsAndConcurrentApps", "TestI18nAdapterFailurePreservesBusiness",
"TestUploadPolicyAcrossTransports", "TestConfiguredBodyPolicyBeforeMiddleware", "TestBodyHardLimitConflictBeforeServing", "TestDefaultBodyLimitRegistrationPaths",
"TestShutdownWaitsForCancellationCleanup", "TestExternalServingEntriesRetainActiveResources", "TestRequestAdmissionAndCloseRace", "TestConfiguredShutdownDoesNotWaitForUncooperativeHandler"},
"lifecycle": {"TestFinishRetainsDependenciesAfterIncompleteHTTPDrain"},
"transport/http2": {"TestShutdownWaitsForHandlerAfterClientDisconnect"},
"transport/native": {"TestApplicationRejectsUnreadBodyPromptly", "TestBodyRejectionWithBusinessHTTP200DoesNotDrain"},
"contract": {"TestBodyPolicyPropagatesOuterLimit", "TestJSONClosingDelimiterPreservesReaderCause"},
"i18n": {"TestIndependentImmutableBundlesAndFallback", "TestResourceAndParameterDiagnostics", "TestStrictLoadingAndExplicitOverlay", "TestPinnedCLDRSamples", "TestBrowserConformance", "TestSourceLocaleCanBeFallbackOnly", "TestTypeScriptConsumer"},
"i18n/httpi18n": {"TestNegotiationAndExplicitResolvers"},
@@ -176,6 +183,11 @@ def main():
checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"]
run(["go", "build", "-o", temp / "httpSafety", MODULE + "/examples/httpSafety"], starter, env)
checks += ["remoteUnifiedBodyPolicyTransports", "remoteBodyLimitsBeforeMiddleware", "remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier", "remoteExternalServingResourceRetention", "remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies", "remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200", "remoteHTTPSafetyExampleBuild"]
transaction_output = run(["go", "run", MODULE + "/examples/transactions"], starter, env)
assert "commitAcknowledgementLost: stage=commit outcome=unknown stored=true cleanup=false" in transaction_output
assert "workFailure: stage=work outcome=rolledBack stored=false cleanup=true" in transaction_output
@@ -231,6 +243,9 @@ def main():
sources += ["database/transaction.go", "database/transaction_test.go", "database/transactionResponse_test.go", "database/mysqlConnector_test.go", "examples/transactions/main.go", "examples/transactions/demoDriver.go"]
sources += [str(p.relative_to(ROOT)) for directory in ("i18n", "examples/i18n") for p in (ROOT / directory).rglob("*") if p.is_file()]
sources += ["app/i18n_test.go", "errors/localization.go", "cmd/go-omni/i18n.go", "cmd/go-omni/apigen/i18nVersion.go", "cmd/go-omni/apigen/testdata/i18n/consumer_test.go", "contract/policy_test.go"]
sources += ["app/body.go", "app/router.go", "app/requests.go", "app/bodyPolicy_test.go", "app/requests_test.go", "app/transport_test.go",
"httpx/context.go", "internal/httplimits/limits.go", "lifecycle/runtime_test.go", "transport/http2/server.go", "transport/http2/lifecycle_test.go",
"transport/native/server.go", "transport/native/earlyReject_test.go", "examples/httpSafety/main.go"]
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Verify the generic project CLI and its consumer from a fresh remote module cache."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import time
from verifyErrorInheritanceRelease import run
ROOT = Path(__file__).resolve().parents[1]
MODULE = "git.yuntuo.hk/k2safe/go-omni"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--report", type=Path, default=ROOT / "reports/programRemote.json")
args = parser.parse_args()
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
parser.error("a fixed release version is required")
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
started = time.monotonic()
with tempfile.TemporaryDirectory(prefix="goOmniProgramRelease-") as directory:
temp = Path(directory)
(temp / "bin").mkdir()
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
print(f"Installing remote CLI {args.version} with an empty module cache", flush=True)
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
cli = temp / "bin/go-omni"
# No version argument and no local replacement: installed CLI owns the default.
starter = temp / "program"
run([cli, "project", "new", starter, "--module", "example.com/released"], temp, env)
run(["go", "mod", "tidy"], starter, env)
dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env))
assert dependency["Version"] == args.version and dependency.get("Sum") and not dependency.get("Replace")
downloaded = Path(dependency["Dir"])
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
print("Testing downloaded generator, shared file transaction and generated program lifecycle", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s",
"./cmd/go-omni/projectgen", "./cmd/go-omni/internal/genfs"], downloaded, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
assert "TestGeneratedProgramConsumer" in passed and "TestConflictRollsBackEarlierWrites" in passed, output
assert not any(e.get("Action") == "fail" for e in events), output
evidence = "".join(e.get("Output", "") for e in events)
assert "no local replace" in evidence, evidence
# Check the committed standalone example with only project-owned additions.
for name in ("internal/config/config.go", "internal/run/run.go", "etc/config.yaml"):
text = (ROOT / "examples/program" / name).read_text().replace("example.com/program", "example.com/released")
(starter / name).write_text(text)
run([cli, "project", "check"], starter, env)
run(["go", "run", ".", "-check-config"], starter, env)
example_output = run(["go", "run", "."], starter, env)
assert "program.completed" in example_output, example_output
run(["go", "test", "./..."], starter, env)
sources = list((ROOT / "cmd/go-omni/projectgen").rglob("*.go"))
sources += list((ROOT / "cmd/go-omni/projectgen/templates").glob("*.tmpl"))
sources += list((ROOT / "cmd/go-omni/internal/genfs").glob("*.go"))
sources += [ROOT / "cmd/go-omni/project.go", ROOT / "scripts/verifyProgramRelease.py"]
report = {
"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2), "tests": passed,
"checks": ["remoteCLIInstall", "installedVersionDefault", "generatedConsumerBuildRaceVet",
"defaultNoAPIFiles", "defaultNoNetworkListener", "finiteProgram", "SIGINT", "SIGTERM",
"initializationFailureAndPanic", "runErrorPanicGoexit", "fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits", "partialStartupDraining", "acknowledgementBeforeResourceClose", "workContextSurvivesGracefulStop",
"cancellationGrace", "unresponsiveRunAndBackgroundWork", "retainedResourcesAndLogger",
"explicitConfigurationOnly", "checkConfigWithoutDependencies", "handwrittenFilesPreserved",
"generatedFileConflict", "missingGeneratedFileRestored", "missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun", "templateDriftUpgrade", "manifestIdentityAndVersionGuards",
"symlinkAndLockProtection", "fileTransactionRollback", "publishedExampleFiniteRun"],
"sourceHashes": {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(sources)},
}
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"PASS remote generic program release {args.version}: {len(report['checks'])} checks", flush=True)
if __name__ == "__main__":
main()

View File

@@ -118,7 +118,7 @@ python3 scripts/verifyTransports.py --version v0.6.0-preview
第一条验证标准库适配器复用完整 Handler / Service / Repository。第二条在源码目录外创建无 replace 的消费模块,从远程版本导入同一套验收包;自研适配器也应使用同一个公共测试包。
当前替换边界分成两层:`transport/nethttp` 提供标准库路由,`transport/native` 进一步接管 TCP 监听、连接管理和 HTTP/1.1 协议 I/O。`App.Run` / `App.RunContext` 仍使用标准库 `net/http.Server`,需要显式调用 `App.RunWith(ctx, native.New(nil), address)` 才会启用 native 预览实现。native 当前通过标准 `http.Request` / `http.ResponseWriter` 桥接到业务层,后续可在不改 Handler / Service 的前提下逐步替换解析器和响应写回。
当前替换边界分成两层:`transport/nethttp` 提供标准库路由,`transport/native` 进一步接管 TCP 监听、连接管理和 HTTP/1.1 协议 I/O。`App.Run` / `App.RunContext` / `ServeContext` 使用配置选中的 transport,默认 nethttp;推荐 `WithTransport(TransportNative)` 启用 native,框架会统一装配限额和 HTTP 配置。自定义 Runner 才使用 `RunWith`。native 通过标准 `http.Request` / `http.ResponseWriter` 桥接到业务层。
## HTTP 配置(v0.9.1-preview)
@@ -152,4 +152,28 @@ I/O 超时不会强制终止 CPU 计算或数据库调用;业务和下游调
YAML/JSON 字段与上述字段对应的 lowerCamelCase 一致,位于 `config.Config` 的 `http` 对象。脚手架将框架配置包在 `server` 下,因此生成项目填写 `server.http`;直接用 `config.LoadFile` 加载框架配置时填写根级 `http`。环境变量由文件显式声明,例如 `readTimeout: "${HTTP_READ_TIMEOUT:-30s}"`,不再按字段自动覆盖。`omni.New` 不自动读取文件;先 `config.LoadFile` / `config.LoadProject`,再 `WithConfig`。Go-only 的 `ErrorLog` 在文件加载之后设置。
`NewServer` 始终返回普通 `*http.Server`,应用 HTTP 字段但不负责加载 TLS、监听、退出或资源清理;非法 HTTP 值会 panic。`RunContext` / `ServeContext` 的非法配置则作为 error 返回并关闭 App 资源。手动修改返回服务器的字段仍然有效。项目自己的 Runner 通过 `RunWith` 接入时,配置由该 Runner 持有,这组 HTTP 配置不会覆盖它。
`NewServer` 始终返回普通 `*http.Server`,应用 HTTP 字段但不负责加载 TLS、监听、退出或资源清理;非法 HTTP 值或路由限额冲突会 panic。`RunContext` / `ServeContext` 的非法配置则作为 error 返回并关闭 App 资源。手动修改返回服务器的字段仍然有效。`RunWith` 的网络配置由 Runner 持有;App 自身的请求体策略和取消收尾窗口仍生效,框架无法自动移除自定义 Runner、反向代理或业务中间件的其他限额。
## 统一请求体限额
三种内置 transport 通过 App 共用下列配置,全部以字节为单位,范围 0..1 TiB:
```yaml
server:
http:
defaultMaxBodyBytes: 1048576 # 普通 App 路由默认 1 MiB
maxBodyBytes: 20971520 # 所有请求硬上限 20 MiB
shutdownCancelTimeout: 5s
```
- `DefaultMaxBodyBytes`:未显式声明限额的 App/RouterGroup 路由默认值;零为 8 MiB,如果硬上限更小则取硬上限。显式默认值超过硬上限会报配置错误。
- `HandleWithBodyLimit` / `.api @request(maxBytes: ...)`:覆盖普通路由默认值。生成契约既有默认 1 MiB 保持;服务默认值不会扩大契约限制。
- `MaxBodyBytes`:服务硬上限;零仅保留框架可支持的 1 TiB 上限,不表示请求会预分配相应内存。路由超过硬上限会在监听前报错,包含完整路由、路由限额、服务限额。`RunWith` 也检查冲突;`NewServer` 在创建时检查,外部宿主在注册完成后调用 `app.ValidateHTTP()` 可预检;未经预检直接调用 App/Engine 时,首次请求拒绝非法配置。
App 在中间件读取请求之前安装流式 reader 限额,支持已知长度和未知长度。native/HTTP2 在 App 装配下只保留 1 TiB 协议上界,原来的隐含 8 MiB 不再挡住放宽的上传路由。请求进入 App 后使用 `BODY_TOO_LARGE` 错误路径;服务硬上限提前拒绝时使用 App 响应策略,路由限额使用该路由策略。native 提前拒绝后先发送响应再半关闭写方向,最多等待 500ms 避免未读上传触发 TCP reset 吞掉提示;不会等待上传完成。错误目录将 HTTP 状态映射为 200 时仍保留请求体拒绝语义。已提交的流式响应不能撤回;处理器应先完成有界读取/校验再执行不可逆业务。
底层协议错误(例如非法 framing 或超过 1 TiB)可能在路由前直接拒绝。直接使用 `native.Server` / `http2.Server` 的 `MaxBodyBytes` 默认仍为 8 MiB,由该服务器的拥有者配置;通过 `RunWith` 传入这些服务器也需自行协调其限额,推荐使用 `WithTransport` 获取统一装配。高级 `Router()` 原始注册绕过 App 路由默认值,但经 App/Engine 服务时仍有服务硬限额;直接调用 Router.ServeHTTP 会绕过整个 App 策略。
multipart 的整个请求包含文件、字段和边界,`MaxFileBytes` 与请求体上限分别控制;幂等中间件 `MaxRequestBytes` 控制缓存量,不随路由自动放大。代理层限额继续在代理配置中维护。框架不猜测业务文件规格或代理策略。
迁移:旧 nethttp 手写路由此前无统一默认 reader 限额,现在默认 8 MiB。大请求用路由覆盖或显式默认值;已有生成接口保留契约限额。示例见 [httpSafety](../examples/httpSafety/README.md)。

View File

@@ -247,3 +247,29 @@ func TestCanceledServeContextWaitsForShutdownDeadline(t *testing.T) {
}
waitSignal(t, canceled)
}
func TestShutdownWaitsForHandlerAfterClientDisconnect(t *testing.T) {
started, canceled, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{})
s := New(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
defer close(finished)
close(started)
<-r.Context().Done()
close(canceled)
<-release
}))
defer func() { close(release); waitSignal(t, finished) }()
address, done := launchServer(t, s, context.Background())
p := newFramePeer(t, address)
p.get(t, 1, "/hold")
waitSignal(t, started)
p.Close()
waitSignal(t, canceled)
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
if err := s.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("disconnected handler reported as drained: %v", err)
}
// Serve can finish its bounded shutdown, but successful Shutdown must never
// imply that a disconnected, still-running handler has completed.
_ = serveResult(t, done)
}

View File

@@ -64,6 +64,7 @@ type Server struct {
activeConns atomic.Int64
activeRequests atomic.Int64
wg sync.WaitGroup
requestWG sync.WaitGroup
}
// Stats is a point-in-time view of HTTP/2 transport admission and lifecycle.
@@ -390,7 +391,8 @@ func (s *Server) tlsHandshakeTimeout() time.Duration {
return defaultTLSHandshake
}
// Shutdown stops accepting connections and waits for active HTTP/2 sessions.
// Shutdown stops accepting connections/requests and waits for both HTTP/2
// sessions and admitted handlers, even when the client has already disconnected.
// If the context expires, remaining connections are closed so handlers can
// observe cancellation through their request contexts.
func (s *Server) Shutdown(ctx context.Context) error {
@@ -436,6 +438,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
done := make(chan struct{})
go func() {
s.wg.Wait()
s.requestWG.Wait()
close(done)
}()
select {
@@ -476,6 +479,17 @@ type requestGate struct {
}
func (g *requestGate) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
// Synchronize admission with Shutdown before it starts waiting. Connection
// completion alone does not join x/net/http2's separate handler goroutines.
g.server.mu.Lock()
if g.server.closing {
g.server.mu.Unlock()
http.Error(writer, "server shutting down", http.StatusServiceUnavailable)
return
}
g.server.requestWG.Add(1)
g.server.mu.Unlock()
defer g.server.requestWG.Done()
if g.slots != nil {
select {
case g.slots <- struct{}{}:

View File

@@ -2,6 +2,7 @@ package native_test
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
@@ -11,6 +12,8 @@ import (
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
@@ -58,3 +61,41 @@ func TestApplicationRejectsUnreadBodyPromptly(t *testing.T) {
})
}
}
func TestBodyRejectionWithBusinessHTTP200DoesNotDrain(t *testing.T) {
app := omni.New(omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
"BODY_TOO_LARGE": {Code: "LIMIT_REJECTED", Message: "body limit exceeded"},
}}))
app.HandleWithBodyLimit("POST", "/upload", 32, nil, func(*omni.Context) error { t.Error("oversize reached handler"); return nil })
server := native.New(app)
server.ReadTimeout = 10 * time.Second
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- server.Serve(context.Background(), l) }()
defer stopServer(t, server, l, done)
defer app.Close()
c, err := net.DialTimeout("tcp", l.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(time.Second))
// Intentionally withhold every body byte. The rejection must be sent before
// any attempted drain, despite the project mapping its HTTP status to 200.
_, err = io.WriteString(c, "POST /upload HTTP/1.1\r\nHost: local\r\nContent-Length: 1024\r\n\r\n")
if err != nil {
t.Fatal(err)
}
res, err := http.ReadResponse(bufio.NewReader(c), nil)
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil || res.StatusCode != 200 || !res.Close || !bytes.Contains(data, []byte("LIMIT_REJECTED")) {
t.Fatalf("status=%d close=%v body=%s err=%v", res.StatusCode, res.Close, data, err)
}
}

View File

@@ -420,7 +420,9 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
status = protocolErr.status
message = protocolErr.message + "\n"
}
_ = writeSimple(state.conn, status, message, true, s.WriteTimeout)
if writeSimple(state.conn, status, message, true, s.WriteTimeout) == nil && status == http.StatusRequestEntityTooLarge {
closeRejectedBody(baseCtx, state.conn)
}
}
return
}
@@ -482,13 +484,15 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
}
handler.ServeHTTP(writer, request)
var bodyErr error
if body, ok := request.Body.(*requestBody); ok && !body.done && body.err == nil && writer.status == http.StatusRequestEntityTooLarge {
rejectedBody := false
if body, ok := request.Body.(*requestBody); ok && !body.done && body.err == nil && (writer.bodyTooLarge || writer.status == http.StatusRequestEntityTooLarge) {
// An application can reject size from the headers alone when its
// body limit is below the transport limit. Waiting to
// drain a slow or absent body would withhold the rejection response.
// Abandon it and close; unread bytes can never become a next request.
body.closed = true
closeAfter = true
rejectedBody = true
} else {
bodyErr = request.Body.Close()
}
@@ -538,11 +542,30 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
s.writerPool.Put(writer)
shouldClose = s.finishRequest(state) || shouldClose
if shouldClose {
if rejectedBody {
closeRejectedBody(baseCtx, state.conn)
}
return
}
}
}
// Half-close after flushing a rejection, then allow the peer to consume it
// before closing a socket with unread upload data. As in net/http, immediate
// full close can cause a TCP reset to hide the response on BSD systems. This
// never waits for the body and cancellation bounds the additional grace.
func closeRejectedBody(ctx context.Context, conn net.Conn) {
if writer, ok := conn.(interface{ CloseWrite() error }); ok {
_ = writer.CloseWrite()
}
timer := time.NewTimer(500 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
// Publish the idle transition under the same lock that starts shutdown. Either
// Shutdown observes an idle connection and closes it, or this request observes
// shutdown and closes it instead of starting another Keep-Alive read.
@@ -1055,6 +1078,7 @@ func writeSimple(conn net.Conn, status int, body string, closeAfter bool, timeou
type responseWriter struct {
header http.Header
bodyTooLarge bool
status int
wroteHeader bool
body bytes.Buffer
@@ -1081,6 +1105,7 @@ func (w *responseWriter) reset(method string) {
}
w.status = 0
w.wroteHeader = false
w.bodyTooLarge = false
if w.body.Cap() > maxPooledResponseBytes {
w.body = bytes.Buffer{}
} else {
@@ -1110,6 +1135,10 @@ func (w *responseWriter) configure(request *http.Request, conn net.Conn, closeAf
func (w *responseWriter) Header() http.Header { return w.header }
// RequestBodyTooLarge preserves semantic body rejection independently from a
// project's chosen HTTP response status. Called through the optional writer hook.
func (w *responseWriter) RequestBodyTooLarge() { w.bodyTooLarge = true }
func (w *responseWriter) WriteHeader(status int) {
if w.wroteHeader {
return