Compare commits

..

15 Commits

28 changed files with 7124 additions and 30 deletions

View File

@@ -1,5 +1,34 @@
# Changelog # Changelog
## Unreleased
- 统一标准库与 native 的真实 TCP benchmark,增加固定并发档位、响应状态与读写错误检查。
- 新增独立进程并发与长连接内存测量工具,记录成功吞吐、P95/P99、服务端分配、空闲连接堆内存和回收情况;修正并行 `ns/op` 与客户端/服务端混合分配的解释。
- 加固 native HTTP/1.1 解析边界:校验 Host、重复 Content-Length、Connection token、Transfer-Encoding、控制字符和响应头名称;补充 HTTP/1.0 Keep-Alive、HEAD、204 以及异常请求测试。
- native 支持带 trailers 的 chunked 请求体,并继续通过 `MaxBodyBytes`、读超时和头部上限控制解析内存与连接占用。
- native 响应实现 `http.Flusher`:HTTP/1.1 流式响应自动使用 chunked framing,普通响应继续自动生成 `Content-Length`。
- native 在长 Handler 运行期间监测客户端断开并取消请求 context,流式写失败同步取消;短请求延迟启动监测,管线数据保留给下一次请求。补充断开、监测退出和长连接复用验收。
- native 增加 `ServeTLS` / `RunTLS` 和 `TLSConfig` 启动入口,App.RunWith 可以直接启用 TLS;补充真实 TLS 客户端握手、请求和优雅退出验收。
- native 请求补齐 `Request.TLS` 连接状态;Linux/arm64 容器通过全量 race、vet、build,以及 TLS 并发、Keep-Alive、慢握手和连接上限回收验收。
- 补充慢请求体超时、大请求体和同连接管线请求验收,确认请求体未完整读取时不会进入业务 Handler,连续请求不会串线。
- 补充 `MaxConnections` 满载拒绝与槽位释放验收,确认请求完成后连接容量可再次使用。
- 增加高频建连/断连的状态池 churn 验收,覆盖连接对象回收与容量复用。
## v0.6.0-preview
- native 网络层完成慢请求头、慢 Content-Length / chunked body、慢 TLS 握手和 TLS 请求元数据验收,未完整读取的请求不会进入业务 Handler。
- 将 native 连接 churn race 验收扩展为 16 个 worker、3,200 次建连/断连,并覆盖明文与 TLS 的连接上限、Keep-Alive 和槽位回收。
- perfcheck 支持最长 30 分钟持续场景,新增 Linux 文件描述符采样;固定记录服务端堆、goroutine、连接数、accept 数和 FD 回收状态。
- 新增 `scripts/verifyLinux.py`,在 `golang:1.25` Linux 容器内统一执行 race、vet、build 和可选 TLS / 非 TLS 压测。
- 完成 Linux/arm64 30 秒高并发回归:128 并发流量、512 条空闲连接,TLS 与非 TLS 均为 0 错误,连接、goroutine 和 FD 回收正常;原始报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`。
## v0.5.0
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端,接管 TCP 监听、连接、Keep-Alive、请求解析、响应写回和优雅关闭。
- 新增 `App.RunWith` / `omni.Runner` 启动边界;native 通过标准 `http.Handler` 接入,现有 Handler、Service 和 Repository 无需修改。
- native 支持 `Content-Length` / chunked 请求体、请求头 / 请求体限制、读写超时和服务关闭时的请求取消;通过对象池和 `MaxConnections` / `MaxConcurrentRequests` 控制高并发内存。
- `omni.New()` 默认仍使用只依赖标准库的 `transport/nethttp` 路由,native 作为显式启用的网络层预览实现。
## v0.4.0 ## v0.4.0
- **破坏性变更**:移除 Gin 适配器及其全部依赖,`omni.New()` 默认使用只依赖标准库的 `transport/nethttp`。 - **破坏性变更**:移除 Gin 适配器及其全部依赖,`omni.New()` 默认使用只依赖标准库的 `transport/nethttp`。

View File

@@ -1,20 +1,84 @@
# GoOmni 性能基线 # GoOmni 性能基线
这份基线用于比较未来自研网络层,不代表生产容量承诺。测试只测进程内请求分发和 GoOmni 请求链,不包含真实 TCP、TLS、数据库或业务存储延迟。 这轮测量显示 native 与标准库处于相同吞吐量级,在本机短时测试中,native 的空闲长连接堆内存更少。当前 native 仅实现 HTTP 的部分能力,尚不能据此判断生产容量或替代完整标准库服务端。
运行: ## 运行方式
```sh ```sh
# race、vet、build,以及进程内和真实 TCP benchmark
python3 scripts/verifyPerformance.py python3 scripts/verifyPerformance.py
# 另加独立服务进程的并发、延迟和空闲长连接内存矩阵
python3 scripts/verifyPerformance.py --load --output /tmp/goOmniPerf.json
# 只运行独立进程矩阵,默认每档 2 秒、3 轮
go run ./internal/perfcheck --duration 2s --count 3 --output /tmp/goOmniPerf.json
# 在 Linux Docker 容器内固定运行 race、vet、build 和 TLS 压测
python3 scripts/verifyLinux.py --load --tls --duration 30s \
--concurrency 128 --idle 512 --count 1 \
--output performance/linuxSoakTLS.json
# 发布候选验收可把持续流量延长到 30 分钟
python3 scripts/verifyLinux.py --load --tls --duration 30m \
--concurrency 128 --idle 512 --count 1 \
--output performance/linuxSoakTLS-30m.json
``` ```
本次记录环境:Go 1.25.0、darwin/arm64、Apple M2 Max;每项连续运行 3 次: 可通过 `--concurrency 1,32,128` / `--idle 128,1024` 调整矩阵;传空字符串可跳过对应场景。工具只监听本机随机端口,每个场景启动独立服务进程,并在结束后关闭客户端、检查连接排空、关闭服务。响应状态、完整响应体、requestId 或传输错误均计入失败,不能把快速返回的错误页算成成功吞吐。
```text Linux 验收脚本使用 `golang:1.25` 容器,固定执行 `go test -race ./...`、`go vet ./...`、`go build ./...`,再按参数运行 perfcheck。perfcheck 在 Linux 读取 `/proc/self/fd`,报告连接建立期间的文件描述符峰值以及回收后的数量;同时记录服务端堆、goroutine、连接数和累计 accept 数。30 秒报告用于快速回归,30 分钟命令用于发布前持续压测。
BenchmarkTransportRouteDispatch/nethttp 568–671 ns/op 1344 B/op 13 allocs/op
BenchmarkTransportApplicationRequest/nethttp 2017–2208 ns/op 3091 B/op 34 allocs/op
```
`BenchmarkTransportRouteDispatch` 只覆盖标准库路由、路径参数和 `http.Handler` 调用;`BenchmarkTransportApplicationRequest` 还包含 GoOmni Context、RequestID、统一 JSON 响应和默认中间件链。 ## 2026-09-21 本机测量
比较新网络层时,应使用相同 Go 版本、机器和 benchmark 命令,并同时运行 `-race` 全量测试。性能下降需要结合分配次数、取消传播、错误响应和业务契约结果一起判断。 环境:Go 1.25.0、darwin/arm64、Apple M2 Max、GOMAXPROCS=12。网络实现来自 `3d5f225` 工作树(native 与 v0.6.0-preview 相同);本次新增测量工具,不修改服务端实现。原始测量值见 [tcpBaseline.json](performance/tcpBaseline.json)。报告时间为 UTC,北京时间是 9 月 21 日。
每个并发档位持续约 2 秒、重复 3 轮,每轮交替标准库/native 的先后顺序。下表为各指标的三轮中位数;全部流量场景共完成 **2,660,828** 次请求,错误 **0**。另有 128 / 1,024 条空闲长连接的建立、保持和回收测试。
| 并发请求 | 标准库成功请求/秒 | native 成功请求/秒 | 标准库 P95 | native P95 | 标准库 P99 | native P99 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | 19,731 | 21,497 | 0.074 ms | 0.070 ms | 0.139 ms | 0.119 ms |
| 32 | 90,099 | 88,812 | 1.016 ms | 0.983 ms | 1.507 ms | 1.507 ms |
| 128 | 112,422 | 112,105 | 3.277 ms | 3.408 ms | 4.719 ms | 4.981 ms |
128 并发时,两者吞吐差约 0.3%,native 的尾延迟略高。基于这组结果,暂时没有理由仅为健康接口吞吐就改成手写事件循环。
| 空闲 Keep-Alive 连接 | 标准库新增堆内存 | native 新增堆内存 | 标准库约每连接 | native 约每连接 |
| --- | ---: | ---: | ---: | ---: |
| 128 | 1.275 MiB | 0.658 MiB | 10,444 B | 5,389 B |
| 1,024 | 9.871 MiB | 5.137 MiB | 10,108 B | 5,260 B |
1,024 条空闲连接时,native 的新增堆内存约少 48%。这是**服务端 Go 堆内存增量**,不包括 goroutine 栈、进程其他内存和内核 socket 缓冲区,不能解释为整个进程内存减半。同档服务端 `StackInuse` 分别约为标准库 8.75 MiB、native 8.69 MiB,原始报告单独记录。
连接断开后,两者连接数都回到 0,goroutine 分别回到 2 / 3 的初始基线。一次 GC 后仍有对象池缓存;第二次 GC 后,总堆内存中位数回到约 2.10 / 2.08 MiB(启动基线约 1.54 MiB)。堆内存不会立刻精确回到启动数值,不把缓存保留解释为泄漏,也不把这轮短测解释为长期无泄漏证明。
128 并发时,**服务端单独累计分配**约为标准库 4,454 B/请求、47 次/请求,native 4,297 B/请求、55 次/请求。native 的分配字节较少、次数较多;具体来源需用分配 profile 确认,不能仅凭总量归因于 HTTP 桥接或 JSON。
## 统计边界
- 同一台机器上,压测客户端和服务端是两个独立进程。两者使用同一健康接口、相同小型 JSON 响应、固定 `requestId=perfCheck` 和 Keep-Alive;不启用访问日志、TLS 或数据库。
- 并发模型为闭环:每个 worker 等上一个请求完成后发下一个。实际经过时间包含最后请求排空;吞吐只计算成功响应。不能据此推断开放到达流量下的排队或过载表现。
- 延迟从客户端发起请求到完整响应校验完成,包含失败请求。直方图使用每个 2 的幂区间内 16 档,报告桶上界,舍入误差至多约 6.25%。小幅差别不应过度解释。
- 服务端 `runtime.MemStats` 通过独立 stdin/stdout 控制通道读取,客户端内存不计入。累计分配差值包含少量采样开销。堆峰值每 200 ms 采样,可能错过瞬时峰值;这些指标不是进程 RSS。
- Linux 文件描述符来自服务端子进程的 `/proc/self/fd`,包含标准输入输出、监听 socket 和连接;macOS 等没有该路径的平台会省略该字段。回收后的数量应回到启动附近,不能代替内核 socket 缓冲或 RSS 测量。
- 空闲场景在所有连接完成健康请求后做 GC 再采样。新增堆内存包括连接、缓冲区及分摊的运行时开销。`after` 为客户端断开并排空后一次 GC,`afterReclaim` 为第二次 GC,用于观察对象池缓存释放。
- native 配置上限为 8,192 连接 / 4,096 活跃请求;本轮并发低于上限,未验证过载保护容量。标准库没有同样的并发闸门;比较包括各自真实实现的开销。
- 可用 `python3 scripts/verifyPerformance.py --load --tls --duration 2s` 运行 TLS 版本的独立进程矩阵。该模式在每个子进程生成短期自签名证书,客户端仅用于本机基准验证;本报告的历史表格仍是无 TLS 数据。
## benchmark 口径修正
## TLS 验收记录
macOS arm64 本机的 5 秒 TLS 矩阵见 [tlsBaseline.json](performance/tlsBaseline.json):1 / 32 / 128 并发和 128 / 1,024 条空闲连接均为 0 错误;native 在 128 并发约 114,232 req/s,1,024 条空闲 TLS 连接的服务端堆增量约 8.94 MiB,连接关闭后 goroutine 回到基线。
Linux arm64 容器使用 Go 1.25.14、GOMAXPROCS=4 运行 3 秒矩阵,见 [tlsBaselineLinux.json](performance/tlsBaselineLinux.json):1 / 32 / 128 并发和 128 / 512 条空闲连接均为 0 错误;native 在 128 并发约 99,876 req/s,512 条空闲 TLS 连接的服务端堆增量约 4.47 MiB,连接关闭后 goroutine 回到基线。容器验证同时通过全量 race、vet、build。
Linux arm64 的 30 秒高并发回归见 [linuxSoak.json](performance/linuxSoak.json) 和 [linuxSoakTLS.json](performance/linuxSoakTLS.json):两份报告均使用 128 并发流量和 512 条空闲连接,所有场景错误为 0;native 非 TLS 约 146,614 req/s、TLS 约 149,720 req/s,空闲连接堆增量分别约 2.57 MiB 和 4.47 MiB,FD 均由 7 增至 519 并回到 7,goroutine 均回到启动附近。
`BenchmarkTransportTCPApplicationRequest/{nethttp,native}/concurrency{1,32,128}` 使用同一测试入口、客户端、应用与响应检查。原先独立的 `BenchmarkNativeApplicationRequest` 已合并,避免两个入口配置漂移。`verifyPerformance.py` 会一并运行。
并行 benchmark 的 **`ns/op` 是总经过时间除以请求数,反映吞吐成本,不是单请求延迟**。`B/op` / `allocs/op` 统计同一进程中的客户端与服务端总分配。此前报告的 native 15.4–16.7 µs/op、约 7.8 KB/op、100 allocs/op 应按这个含义理解,不能作为服务端延迟或独立内存指标。
`BenchmarkTransportRouteDispatch` 和 `BenchmarkTransportApplicationRequest` 继续保留,分别测进程内路由分发和完整框架处理链;不包含 TCP,不能直接与上述网络延迟比较。
后续通过 30 分钟命令完成发布前持续压测,并在目标反向代理和部署环境继续观察内核 socket 缓冲、FD 上限与真实业务流量;再通过 profile 决定是否优化对象分配或事件状态机。

View File

@@ -5,7 +5,7 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本: 远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
```sh ```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.4.0 go get git.yuntuo.hk/k2safe/go-omni@v0.6.0-preview
``` ```
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。 `v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
@@ -13,7 +13,7 @@ go get git.yuntuo.hk/k2safe/go-omni@v0.4.0
创建新业务项目: 创建新业务项目:
```sh ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.4.0 go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.6.0-preview
go-omni new --module example.com/billing ./billing go-omni new --module example.com/billing ./billing
cd billing cd billing
go mod tidy go mod tidy
@@ -43,6 +43,7 @@ go-omni/
├── auth/ # 认证和权限接口 ├── auth/ # 认证和权限接口
├── database/ # database/sql 生命周期和事务 ├── database/ # database/sql 生命周期和事务
├── transport/nethttp/ # 标准库网络适配器 ├── transport/nethttp/ # 标准库网络适配器
├── transport/native/ # 自研 HTTP/1.1 服务端预览
├── examples/ # 示例项目 ├── examples/ # 示例项目
└── cmd/ # go-omni 项目脚手架 └── cmd/ # go-omni 项目脚手架
``` ```
@@ -94,6 +95,34 @@ application := omni.NewWithRouter(myRouter)
`app.Engine()` 返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,业务 API 不锁定具体网络实现。 `app.Engine()` 返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,业务 API 不锁定具体网络实现。
需要试用第一版自研网络层时,业务注册代码无需改变,只替换启动器:
```go
import (
"context"
"log"
"os"
"os/signal"
"syscall"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
app := omni.New()
app.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil {
log.Fatal(err)
}
```
`transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 和 chunked 请求体、`http.Flusher` 流式响应、可选 TLS、读写超时和优雅关闭;不支持的传输编码会被拒绝。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库: 业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库:
```go ```go
@@ -171,12 +200,12 @@ userService := service.NewUsers(services)
从远程版本生成并升级一个独立业务模块,可运行: 从远程版本生成并升级一个独立业务模块,可运行:
```sh ```sh
python3 scripts/verifyBusiness.py --version v0.4.0 python3 scripts/verifyBusiness.py --version v0.6.0-preview
``` ```
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.4.0`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。 该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.6.0-preview`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
标准库路由和完整 GoOmni 请求链的可重复性能基线见 [PERFORMANCE.md](PERFORMANCE.md),运行 `python3 scripts/verifyPerformance.py` 可重新采样。 标准库与 native 的并发、延迟和长连接内存对照见 [PERFORMANCE.md](PERFORMANCE.md)。`python3 scripts/verifyPerformance.py` 运行检查与 benchmark;增加 `--load --output /tmp/goOmniPerf.json` 可测量独立服务进程并保存报告。
在框架根目录运行: 在框架根目录运行:

View File

@@ -35,10 +35,20 @@
- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。 - 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。
- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.4.0`。 - 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.4.0`。
## 5. 稳定性与性能基线(进行中) ## 5. 自研网络层、稳定性与性能基线(v0.6.0-preview 验收完成)
- 已加入标准库路由和完整 GoOmni 请求链 benchmark,输出分配次数和耗时,作为未来自研网络层的对照基线。 - 已加入标准库路由和完整 GoOmni 请求链 benchmark,输出分配次数和耗时,作为未来自研网络层的对照基线。
- 已具备全量 race、vet、build 验收;生产压测、长连接和真实部署规模验证仍待完成。 - 新增 `transport/native` 第一版自研 HTTP/1.1 服务端:自行管理 TCP listener、Keep-Alive、Content-Length / chunked 请求解析、响应写回、读写超时和优雅关闭;通过 `App.RunWith` 接入,业务 Handler / Service 保持不变。
- 自动验收入口:`python3 scripts/verifyPerformance.py`。 - 标准库与 native 共用 TCP benchmark,固定 1 / 32 / 128 并发;相同应用、客户端、超时和响应检查,避免两个测试入口逐渐失去可比性。
- 新增独立服务进程压测 `internal/perfcheck`,记录成功吞吐、P50/P95/P99、服务端分配、采样堆峰值,以及 128 / 1,024 条空闲长连接的堆内存、goroutine 和断开后的回收情况。原始报告保存在 `performance/tcpBaseline.json`。
- native 已支持受 `MaxBodyBytes` 约束的 `Content-Length` / chunked 请求体,仍使用标准 `http.Request` / `http.ResponseWriter` 作为桥接;解析器、连接抽象和生产压测将在后续阶段继续下沉。
- native 响应已支持标准 `http.Flusher`;HTTP/1.1 流式响应使用 chunked framing,普通响应继续走 `Content-Length`,HTTP/1.0 回退到关闭连接分帧。
- native 长请求已补客户端断开及流式写失败取消,监测退出后再复用 Reader;已验收普通/流式断开、慢 Handler 后连续 Keep-Alive 和管线数据不被误消费。短请求延迟启动监测,已有管线数据时取消依赖后续写失败或 Handler 返回。
- 已补充慢请求头、慢 Content-Length body、慢 chunked body、慢 TLS 握手、大请求体、HTTP/1.0 Keep-Alive、HEAD/204 和管线请求验收;Linux/arm64 容器已通过全量 race、vet、build,以及 3 秒 TLS 并发/空闲连接矩阵。原始报告见 [tlsBaselineLinux.json](performance/tlsBaselineLinux.json)。
- 已验证 `MaxConnections` 满载时快速拒绝新连接、请求完成后释放容量;后续仍需在目标部署环境观察文件描述符、内核 socket 缓冲和反向代理联动。
- 已用 16 个并发 worker 完成 3,200 次建连/断连 race 验收;MaxConnections 满载拒绝、槽位释放和 TLS 连接上限均已覆盖。
- 新增 `scripts/verifyLinux.py`,固定在 Go 1.25 Linux 容器内运行 race、vet、build 和可选 TLS/非 TLS perfcheck;perfcheck 记录堆、goroutine、连接和 Linux 文件描述符。
- Linux/arm64 已完成 30 秒、128 并发、512 空闲连接的 TLS 与非 TLS 持续回归,报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`,均为 0 错误且连接、goroutine、FD 回收正常。
- 自动验收入口:`python3 scripts/verifyPerformance.py`;Linux 固定验收入口:`python3 scripts/verifyLinux.py --load --tls --duration 30m`。真实部署仍需按目标代理和内核参数执行 30 分钟发布前压测。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,稳定性与性能验证、自研网络内核尚未完成。 以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。

View File

@@ -29,6 +29,13 @@ type App struct {
closeErr error closeErr error
} }
// Runner is the small lifecycle boundary used by an HTTP server
// implementation. The default App.RunContext uses net/http; a custom runner
// can own the listener, protocol parser and connection lifecycle.
type Runner interface {
Run(context.Context, http.Handler, string) error
}
// New creates an application using GoOmni's standard-library router. // New creates an application using GoOmni's standard-library router.
func New(options ...config.Option) *App { func New(options ...config.Option) *App {
settings := configure(options...) settings := configure(options...)
@@ -150,6 +157,25 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
return a.serveContext(ctx, server, listener) return a.serveContext(ctx, server, listener)
} }
// RunWith starts a custom server runner while keeping App-owned cleanup in the
// same lifecycle. The runner receives the transport-neutral App handler.
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) error {
if runner == nil {
return errors.Join(errors.New("go-omni: nil runner"), a.Close())
}
if ctx == nil {
ctx = context.Background()
}
listenOn := a.config.Address
if listenOn == "" {
listenOn = ":8080"
}
if len(address) > 0 && address[0] != "" {
listenOn = address[0]
}
return errors.Join(runner.Run(ctx, a, listenOn), a.Close())
}
func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error { func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error {
serveErr := make(chan error, 1) serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }() go func() { serveErr <- server.Serve(listener) }()

View File

@@ -3,7 +3,7 @@
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架: 从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.4.0 go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.6.0-preview
go-omni new --module example.com/billing ./billing go-omni new --module example.com/billing ./billing
cd billing cd billing
go mod tidy go mod tidy
@@ -16,7 +16,7 @@ go run ./cmd/server
需要选择其他已发布版本时,使用完整版本号: 需要选择其他已发布版本时,使用完整版本号:
```sh ```sh
go-omni new --framework-version v0.4.0 --module example.com/billing ./billing go-omni new --framework-version v0.6.0-preview --module example.com/billing ./billing
``` ```
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。 `--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
@@ -48,8 +48,8 @@ MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖: 发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
```sh ```sh
python3 scripts/verifyScaffold.py --version v0.4.0 python3 scripts/verifyScaffold.py --version v0.6.0-preview
python3 scripts/verifyDatabase.py --version v0.4.0 python3 scripts/verifyDatabase.py --version v0.6.0-preview
``` ```
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、 脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty). // Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their // Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum. // downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.4.0" const defaultFrameworkVersion = "v0.6.0-preview"
var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)

678
internal/perfcheck/main.go Normal file
View File

@@ -0,0 +1,678 @@
// perfcheck compares real TCP servers in separate processes. It is a local
// measurement tool, not part of the framework's public CLI or server API.
package main
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"math/big"
"math/bits"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
const expectedBody = `{"code":"OK","message":"success","data":{"status":"ok"},"requestId":"perfCheck"}`
type snapshot struct {
Address string `json:"address,omitempty"`
HeapAlloc uint64 `json:"heapAlloc"`
HeapInuse uint64 `json:"heapInuse"`
StackInuse uint64 `json:"stackInuse"`
TotalAlloc uint64 `json:"totalAlloc"`
Mallocs uint64 `json:"mallocs"`
NumGC uint32 `json:"numGc"`
Goroutines int `json:"goroutines"`
Connections int64 `json:"connections"`
Accepted int64 `json:"accepted"`
FileDescriptors int `json:"fileDescriptors,omitempty"`
}
type result struct {
Adapter string `json:"adapter"`
Scenario string `json:"scenario"`
Concurrency int `json:"concurrency"`
Run int `json:"run"`
Requests uint64 `json:"requests,omitempty"`
Errors uint64 `json:"errors"`
FirstError string `json:"firstError,omitempty"`
ElapsedSeconds float64 `json:"elapsedSeconds,omitempty"`
RequestsPerSecond float64 `json:"requestsPerSecond,omitempty"`
P50Millis float64 `json:"p50Millis,omitempty"`
P95Millis float64 `json:"p95Millis,omitempty"`
P99Millis float64 `json:"p99Millis,omitempty"`
ServerBytesPerRequest float64 `json:"serverBytesPerRequest,omitempty"`
ServerAllocsPerRequest float64 `json:"serverAllocsPerRequest,omitempty"`
SampledPeakHeapAlloc uint64 `json:"sampledPeakHeapAlloc,omitempty"`
SampledPeakFileDescriptors int `json:"sampledPeakFileDescriptors,omitempty"`
IdleHeapDelta int64 `json:"idleHeapDelta,omitempty"`
IdleHeapPerConnection float64 `json:"idleHeapPerConnection,omitempty"`
Before snapshot `json:"before"`
During snapshot `json:"during"`
After snapshot `json:"after"`
AfterReclaim snapshot `json:"afterReclaim"`
}
type report struct {
CreatedAt string `json:"createdAt"`
GoVersion string `json:"goVersion"`
Platform string `json:"platform"`
GOMAXPROCS int `json:"gomaxprocs"`
Duration string `json:"duration"`
TLS bool `json:"tls"`
Notes []string `json:"notes"`
Results []result `json:"results"`
}
type trackedListener struct {
net.Listener
active, accepted atomic.Int64
}
type trackedConn struct {
net.Conn
once sync.Once
active *atomic.Int64
}
func (l *trackedListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
l.active.Add(1)
l.accepted.Add(1)
return &trackedConn{Conn: c, active: &l.active}, nil
}
func (c *trackedConn) Close() error {
err := c.Conn.Close()
c.once.Do(func() { c.active.Add(-1) })
return err
}
func testTLSConfig() (*tls.Config, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
if err != nil {
return nil, err
}
now := time.Now()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: now.Add(-time.Minute),
NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}},
MinVersion: tls.VersionTLS12,
}, nil
}
func main() {
serverMode := flag.String("server", "", "internal server subprocess: nethttp or native")
tlsEnabled := flag.Bool("tls", false, "run the comparison over TLS")
duration := flag.Duration("duration", 2*time.Second, "measured duration per traffic case")
concurrencyText := flag.String("concurrency", "1,32,128", "traffic concurrency levels")
idleText := flag.String("idle", "128,1024", "established idle Keep-Alive connection levels")
runs := flag.Int("count", 3, "repetitions; adapter order alternates each repetition")
output := flag.String("output", "", "optional JSON report path")
flag.Parse()
var err error
if *serverMode != "" {
err = serve(*serverMode, *tlsEnabled)
} else {
err = compare(*duration, *concurrencyText, *idleText, *runs, *output, *tlsEnabled)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func serve(adapter string, tlsEnabled bool) error {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
tracked := &trackedListener{Listener: listener}
var serveListener net.Listener = tracked
if tlsEnabled {
config, configErr := testTLSConfig()
if configErr != nil {
listener.Close()
return configErr
}
serveListener = tls.NewListener(tracked, config)
}
application := omni.New()
application.GET("/health", func(c *omni.Context) error {
return c.OK(map[string]string{"status": "ok"})
})
done := make(chan error, 1)
var shutdown func(context.Context) error
switch adapter {
case "native":
server := native.New(application)
server.MaxConnections = 8192
server.MaxConcurrentRequests = 4096
shutdown = server.Shutdown
go func() { done <- server.Serve(context.Background(), serveListener) }()
case "nethttp":
server := &http.Server{
Handler: application, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: time.Minute, MaxHeaderBytes: 1 << 20,
}
shutdown = server.Shutdown
go func() {
err := server.Serve(serveListener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
done <- err
}()
default:
listener.Close()
return fmt.Errorf("unknown server %q", adapter)
}
stop := func() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdown(ctx); err != nil {
return err
}
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
encoder := json.NewEncoder(os.Stdout)
if err := encoder.Encode(snapshot{Address: listener.Addr().String()}); err != nil {
return err
}
decoder := json.NewDecoder(os.Stdin)
for {
var command string
if err := decoder.Decode(&command); err != nil || command == "stop" {
return stop()
}
if command == "gc" {
runtime.GC()
}
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
if err := encoder.Encode(snapshot{
HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse,
StackInuse: memory.StackInuse, TotalAlloc: memory.TotalAlloc,
Mallocs: memory.Mallocs, NumGC: memory.NumGC,
Goroutines: runtime.NumGoroutine(), Connections: tracked.active.Load(), Accepted: tracked.accepted.Load(),
FileDescriptors: openFileDescriptorCount(),
}); err != nil {
return err
}
}
}
func openFileDescriptorCount() int {
entries, err := os.ReadDir("/proc/self/fd")
if err != nil {
return 0
}
return len(entries)
}
type child struct {
cmd *exec.Cmd
input io.WriteCloser
encoder *json.Encoder
decoder *json.Decoder
address string
tls bool
cancel context.CancelFunc
closed bool
}
func startChild(adapter string) (*child, error) {
return startChildMode(adapter, false)
}
func startChildMode(adapter string, tlsEnabled bool) (*child, error) {
executable, err := os.Executable()
if err != nil {
return nil, err
}
// This bounds even a broken server or protocol read; it is not a load
// duration. Keep it above the supported 30-minute soak plus drain time.
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Minute)
args := []string{"--server", adapter}
if tlsEnabled {
args = append(args, "--tls")
}
c := &child{cmd: exec.CommandContext(ctx, executable, args...), cancel: cancel, tls: tlsEnabled}
c.cmd.Stderr = os.Stderr
if c.input, err = c.cmd.StdinPipe(); err != nil {
cancel()
return nil, err
}
output, err := c.cmd.StdoutPipe()
if err != nil {
cancel()
c.input.Close()
return nil, err
}
if err := c.cmd.Start(); err != nil {
cancel()
c.input.Close()
output.Close()
return nil, err
}
c.encoder, c.decoder = json.NewEncoder(c.input), json.NewDecoder(output)
var ready snapshot
if err := c.decoder.Decode(&ready); err != nil {
cancel()
c.cmd.Wait()
return nil, err
}
c.address = ready.Address
return c, nil
}
func (c *child) snapshot(gc bool) (snapshot, error) {
command := "snapshot"
if gc {
command = "gc"
}
var value snapshot
if err := c.encoder.Encode(command); err != nil {
return value, err
}
err := c.decoder.Decode(&value)
return value, err
}
func (c *child) close() error {
if c.closed {
return nil
}
c.closed = true
defer c.cancel()
err := c.encoder.Encode("stop")
c.input.Close()
return errors.Join(err, c.cmd.Wait())
}
// Histograms use 16 subdivisions per power of two. Percentiles are bucket
// upper bounds (at most about 6.25% rounding), with bounded client memory.
type histogram [1024]uint64
func (h *histogram) add(d time.Duration) {
n := uint64(max(d.Nanoseconds(), 1))
exponent := bits.Len64(n) - 1
base := uint64(1) << exponent
sub := (n - base) * 16 / base
h[exponent*16+int(sub)]++
}
func (h *histogram) percentile(count uint64, percent uint64) float64 {
target := (count*percent + 99) / 100
if target == 0 {
return 0
}
var seen uint64
for bucket, n := range h {
seen += n
if seen >= target {
base := uint64(1) << (bucket / 16)
return float64(base+uint64(bucket%16+1)*base/16) / float64(time.Millisecond)
}
}
return 0
}
func exchange(client *http.Client, address string) error {
return exchangeMode(client, address, false)
}
func exchangeMode(client *http.Client, address string, tlsEnabled bool) error {
scheme := "http"
if tlsEnabled {
scheme = "https"
}
req, err := http.NewRequest(http.MethodGet, scheme+"://"+address+"/health", nil)
if err != nil {
return err
}
req.Header.Set("X-Request-ID", "perfCheck")
response, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 4096))
closeErr := response.Body.Close()
if response.StatusCode != 200 || response.Header.Get("X-Request-ID") != "perfCheck" || strings.TrimSpace(string(body)) != expectedBody {
return fmt.Errorf("invalid response status=%d body=%q", response.StatusCode, body)
}
return errors.Join(readErr, closeErr)
}
func traffic(c *child, concurrency int, duration time.Duration) (result, error) {
r := result{Scenario: "traffic", Concurrency: concurrency}
transport := &http.Transport{
MaxIdleConns: concurrency, MaxIdleConnsPerHost: concurrency,
MaxConnsPerHost: concurrency, IdleConnTimeout: time.Minute,
}
if c.tls {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // test certificate is self-signed
}
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
defer client.CloseIdleConnections()
// Warm every worker before forcing a baseline GC in the server.
warmErrors := make(chan error, concurrency)
for i := 0; i < concurrency; i++ {
go func() { warmErrors <- exchangeMode(client, c.address, c.tls) }()
}
for i := 0; i < concurrency; i++ {
if err := <-warmErrors; err != nil {
return r, err
}
}
var err error
if r.Before, err = c.snapshot(true); err != nil {
return r, err
}
type workerResult struct {
latencies histogram
count, errors uint64
firstError string
}
workers := make([]workerResult, concurrency)
start := make(chan struct{})
var group sync.WaitGroup
var deadline time.Time
for i := range workers {
group.Add(1)
go func(w *workerResult) {
defer group.Done()
<-start
for time.Now().Before(deadline) {
began := time.Now()
err := exchangeMode(client, c.address, c.tls)
w.latencies.add(time.Since(began))
w.count++
if err != nil {
w.errors++
if w.firstError == "" {
w.firstError = err.Error()
}
}
}
}(&workers[i])
}
done := make(chan struct{})
go func() { group.Wait(); close(done) }()
begin := time.Now()
deadline = begin.Add(duration)
close(start)
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
var sampleErr error
loading := true
for loading {
select {
case <-done:
loading = false
case <-ticker.C:
s, err := c.snapshot(false)
if err != nil {
sampleErr = err
} else if s.HeapAlloc > r.SampledPeakHeapAlloc {
r.SampledPeakHeapAlloc = s.HeapAlloc
}
if s.FileDescriptors > r.SampledPeakFileDescriptors {
r.SampledPeakFileDescriptors = s.FileDescriptors
}
}
}
r.ElapsedSeconds = time.Since(begin).Seconds()
if sampleErr != nil {
return r, sampleErr
}
var latencies histogram
for _, w := range workers {
r.Requests += w.count
r.Errors += w.errors
if r.FirstError == "" {
r.FirstError = w.firstError
}
for i, n := range w.latencies {
latencies[i] += n
}
}
if r.During, err = c.snapshot(false); err != nil {
return r, err
}
r.SampledPeakHeapAlloc = max(r.SampledPeakHeapAlloc, r.During.HeapAlloc)
r.SampledPeakFileDescriptors = max(r.SampledPeakFileDescriptors, r.During.FileDescriptors)
if r.Requests == 0 {
return r, errors.New("no requests completed")
}
r.RequestsPerSecond = float64(r.Requests-r.Errors) / r.ElapsedSeconds
r.P50Millis = latencies.percentile(r.Requests, 50)
r.P95Millis = latencies.percentile(r.Requests, 95)
r.P99Millis = latencies.percentile(r.Requests, 99)
r.ServerBytesPerRequest = float64(r.During.TotalAlloc-r.Before.TotalAlloc) / float64(r.Requests)
r.ServerAllocsPerRequest = float64(r.During.Mallocs-r.Before.Mallocs) / float64(r.Requests)
client.CloseIdleConnections()
r.After, err = drained(c)
if err == nil {
r.AfterReclaim, err = c.snapshot(true)
}
return r, err
}
func idle(c *child, count int) (result, error) {
r := result{Scenario: "idle", Concurrency: count}
var err error
if r.Before, err = c.snapshot(true); err != nil {
return r, err
}
connections := make([]net.Conn, 0, count)
defer func() {
for _, conn := range connections {
conn.Close()
}
}()
for i := 0; i < count; i++ {
conn, err := net.DialTimeout("tcp", c.address, 5*time.Second)
if err != nil {
return r, err
}
if c.tls {
secure := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: "localhost"}) // test certificate is self-signed
conn = secure
}
connections = append(connections, conn)
conn.SetDeadline(time.Now().Add(5 * time.Second))
if _, err := io.WriteString(conn, "GET /health HTTP/1.1\r\nHost: localhost\r\nX-Request-ID: perfCheck\r\n\r\n"); err != nil {
return r, err
}
response, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodGet})
if err != nil {
return r, err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil || response.StatusCode != 200 || strings.TrimSpace(string(body)) != expectedBody || response.Close {
return r, fmt.Errorf("invalid Keep-Alive response: status=%d error=%v", response.StatusCode, readErr)
}
conn.SetDeadline(time.Time{})
}
if r.During, err = c.snapshot(true); err != nil {
return r, err
}
if r.During.Connections != int64(count) {
return r, fmt.Errorf("idle connections: want %d, have %d", count, r.During.Connections)
}
if r.During.FileDescriptors > 0 && r.Before.FileDescriptors > 0 && r.During.FileDescriptors < r.Before.FileDescriptors+count {
return r, fmt.Errorf("idle file descriptors: want at least %d, have %d", r.Before.FileDescriptors+count, r.During.FileDescriptors)
}
r.IdleHeapDelta = int64(r.During.HeapAlloc) - int64(r.Before.HeapAlloc)
r.IdleHeapPerConnection = float64(r.IdleHeapDelta) / float64(count)
for _, conn := range connections {
conn.Close()
}
connections = nil
r.After, err = drained(c)
if err == nil {
r.AfterReclaim, err = c.snapshot(true)
}
return r, err
}
func drained(c *child) (snapshot, error) {
deadline := time.Now().Add(5 * time.Second)
for {
s, err := c.snapshot(false)
if err != nil {
return s, err
}
if s.Connections == 0 {
return c.snapshot(true)
}
if time.Now().After(deadline) {
return s, fmt.Errorf("%d connections remained after clients closed", s.Connections)
}
time.Sleep(10 * time.Millisecond)
}
}
func levels(text string) ([]int, error) {
var values []int
if text == "" {
return values, nil
}
for _, item := range strings.Split(text, ",") {
n, err := strconv.Atoi(strings.TrimSpace(item))
if err != nil || n < 1 || n > 4096 {
return nil, fmt.Errorf("invalid level %q (expected 1..4096)", item)
}
values = append(values, n)
}
return values, nil
}
func compare(duration time.Duration, concurrencyText, idleText string, count int, output string, tlsEnabled bool) error {
concurrency, err := levels(concurrencyText)
if err != nil {
return err
}
idleLevels, err := levels(idleText)
if err != nil {
return err
}
if count < 1 || duration < 200*time.Millisecond || duration > 30*time.Minute || len(concurrency)+len(idleLevels) == 0 {
return errors.New("count must be positive, duration 200ms..30m, and at least one scenario selected")
}
report := report{
CreatedAt: time.Now().UTC().Format(time.RFC3339), GoVersion: runtime.Version(),
Platform: runtime.GOOS + "/" + runtime.GOARCH, GOMAXPROCS: runtime.GOMAXPROCS(0), Duration: duration.String(), TLS: tlsEnabled,
Notes: []string{
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with <=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"FileDescriptors is read from /proc/self/fd on Linux and omitted on other platforms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection.",
},
}
var caseErrors []error
for run := 1; run <= count; run++ {
adapters := []string{"nethttp", "native"}
if run%2 == 0 {
adapters[0], adapters[1] = adapters[1], adapters[0]
}
for _, scenario := range []struct {
name string
levels []int
}{{"traffic", concurrency}, {"idle", idleLevels}} {
for _, level := range scenario.levels {
for _, adapter := range adapters {
c, err := startChildMode(adapter, tlsEnabled)
if err != nil {
return err
}
var value result
if scenario.name == "traffic" {
value, err = traffic(c, level, duration)
} else {
value, err = idle(c, level)
}
err = errors.Join(err, c.close())
value.Adapter, value.Run = adapter, run
if err != nil {
value.Errors++
value.FirstError = err.Error()
}
report.Results = append(report.Results, value)
if value.Errors > 0 {
caseErrors = append(caseErrors, fmt.Errorf("%s %s/%d run %d: %s", adapter, scenario.name, level, run, value.FirstError))
}
if scenario.name == "traffic" {
fmt.Printf("%s concurrency=%d run=%d: %.0f req/s p95=%.3fms p99=%.3fms server=%.0f B/req errors=%d\n", adapter, level, run, value.RequestsPerSecond, value.P95Millis, value.P99Millis, value.ServerBytesPerRequest, value.Errors)
} else {
fmt.Printf("%s idle=%d run=%d: server heap delta=%.2f MiB (%.0f B/conn), goroutines=%d->%d->%d, fds=%d->%d->%d errors=%d\n", adapter, level, run, float64(value.IdleHeapDelta)/(1<<20), value.IdleHeapPerConnection, value.Before.Goroutines, value.During.Goroutines, value.After.Goroutines, value.Before.FileDescriptors, value.During.FileDescriptors, value.After.FileDescriptors, value.Errors)
}
}
}
}
}
if output != "" {
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(output, append(data, '\n'), 0644); err != nil {
return err
}
fmt.Printf("Report: %s\n", output)
}
return errors.Join(caseErrors...)
}

View File

@@ -0,0 +1,75 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// A load generator must never report a fast failure page as good throughput.
func TestExchangeRejectsInvalidResponses(t *testing.T) {
for _, tt := range []struct {
name string
status int
body string
requestID string
truncated bool
wantOK bool
}{
{"valid", 200, expectedBody, "perfCheck", false, true},
{"overload", 503, "server busy", "perfCheck", false, false},
{"wrongPayload", 200, `{"data":"wrong"}`, "perfCheck", false, false},
{"missingRequestId", 200, expectedBody, "", false, false},
{"truncated", 200, expectedBody, "perfCheck", true, false},
} {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Request-ID") != "perfCheck" {
t.Error("client did not send fixed requestId")
}
w.Header().Set("X-Request-ID", tt.requestID)
if tt.truncated {
w.Header().Set("Content-Length", "4096")
}
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
err := exchange(server.Client(), strings.TrimPrefix(server.URL, "http://"))
if (err == nil) != tt.wantOK {
t.Fatalf("valid=%v, error=%v", tt.wantOK, err)
}
})
}
}
func TestExchangeModeTLS(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Request-ID", "perfCheck")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(expectedBody))
}))
defer server.Close()
if err := exchangeMode(server.Client(), strings.TrimPrefix(server.URL, "https://"), true); err != nil {
t.Fatal(err)
}
}
func TestPercentileBounds(t *testing.T) {
var h histogram
// Known ranks, including a tail much slower than the median.
for i := 1; i <= 100; i++ {
h.add(time.Duration(i) * time.Millisecond)
}
for _, p := range []uint64{50, 95, 99} {
value := h.percentile(100, p)
if value < float64(p) || value > float64(p)*1.0625 {
t.Fatalf("p%d=%gms outside advertised bucket rounding", p, value)
}
}
if value := h.percentile(0, 95); value != 0 {
t.Fatalf("empty histogram returned %g", value)
}
}

View File

@@ -13,6 +13,7 @@ import (
) )
type App = app.App type App = app.App
type Runner = app.Runner
type RouterGroup = app.RouterGroup type RouterGroup = app.RouterGroup
type Principal = auth.Principal type Principal = auth.Principal
type Authenticator = auth.Authenticator type Authenticator = auth.Authenticator

265
performance/linuxSoak.json Normal file
View File

@@ -0,0 +1,265 @@
{
"createdAt": "2026-09-21T17:29:24Z",
"goVersion": "go1.25.14",
"platform": "linux/arm64",
"gomaxprocs": 4,
"duration": "30s",
"tls": false,
"notes": [
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with \u003c=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"FileDescriptors is read from /proc/self/fd on Linux and omitted on other platforms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection."
],
"results": [
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 4456189,
"errors": 0,
"elapsedSeconds": 30.001393348,
"requestsPerSecond": 148532.7347403705,
"p50Millis": 0.589824,
"p95Millis": 2.62144,
"p99Millis": 3.538944,
"serverBytesPerRequest": 4427.911252417705,
"serverAllocsPerRequest": 47.07614668049313,
"sampledPeakHeapAlloc": 6219064,
"sampledPeakFileDescriptors": 135,
"before": {
"heapAlloc": 2105600,
"heapInuse": 4202496,
"stackInuse": 1015808,
"totalAlloc": 4172144,
"mallocs": 30855,
"numGc": 2,
"goroutines": 58,
"connections": 56,
"accepted": 56,
"fileDescriptors": 63
},
"during": {
"heapAlloc": 3500920,
"heapInuse": 5947392,
"stackInuse": 2097152,
"totalAlloc": 19735781560,
"mallocs": 209811062,
"numGc": 6570,
"goroutines": 130,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"after": {
"heapAlloc": 2828640,
"heapInuse": 5054464,
"stackInuse": 1015808,
"totalAlloc": 19735811920,
"mallocs": 209811396,
"numGc": 6571,
"goroutines": 2,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1722584,
"heapInuse": 3530752,
"stackInuse": 1015808,
"totalAlloc": 19735822392,
"mallocs": 209811421,
"numGc": 6572,
"goroutines": 2,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 4398474,
"errors": 0,
"elapsedSeconds": 30.000375472,
"requestsPerSecond": 146613.96501871088,
"p50Millis": 0.622592,
"p95Millis": 2.62144,
"p99Millis": 3.670016,
"serverBytesPerRequest": 4591.297385411395,
"serverAllocsPerRequest": 59.0589788640333,
"sampledPeakHeapAlloc": 4980920,
"sampledPeakFileDescriptors": 135,
"before": {
"heapAlloc": 2202712,
"heapInuse": 4120576,
"stackInuse": 1671168,
"totalAlloc": 4300920,
"mallocs": 32647,
"numGc": 2,
"goroutines": 131,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"during": {
"heapAlloc": 4382752,
"heapInuse": 6021120,
"stackInuse": 1638400,
"totalAlloc": 20199003096,
"mallocs": 259802030,
"numGc": 9032,
"goroutines": 131,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"after": {
"heapAlloc": 2260152,
"heapInuse": 4382720,
"stackInuse": 557056,
"totalAlloc": 20199025048,
"mallocs": 259802362,
"numGc": 9033,
"goroutines": 3,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1645880,
"heapInuse": 3489792,
"stackInuse": 557056,
"totalAlloc": 20199035520,
"mallocs": 259802387,
"numGc": 9034,
"goroutines": 3,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 5162344,
"idleHeapPerConnection": 10082.703125,
"before": {
"heapAlloc": 1516408,
"heapInuse": 3301376,
"stackInuse": 458752,
"totalAlloc": 3006544,
"mallocs": 23238,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0,
"fileDescriptors": 7
},
"during": {
"heapAlloc": 6678752,
"heapInuse": 8675328,
"stackInuse": 4718592,
"totalAlloc": 10467200,
"mallocs": 59319,
"numGc": 4,
"goroutines": 514,
"connections": 512,
"accepted": 512,
"fileDescriptors": 519
},
"after": {
"heapAlloc": 6108264,
"heapInuse": 8069120,
"stackInuse": 491520,
"totalAlloc": 10563168,
"mallocs": 60468,
"numGc": 5,
"goroutines": 2,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1806072,
"heapInuse": 3629056,
"stackInuse": 491520,
"totalAlloc": 10573944,
"mallocs": 60497,
"numGc": 6,
"goroutines": 2,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 2692536,
"idleHeapPerConnection": 5258.859375,
"before": {
"heapAlloc": 1511104,
"heapInuse": 3309568,
"stackInuse": 425984,
"totalAlloc": 3001496,
"mallocs": 23227,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0,
"fileDescriptors": 7
},
"during": {
"heapAlloc": 4203640,
"heapInuse": 6283264,
"stackInuse": 4718592,
"totalAlloc": 8087336,
"mallocs": 58784,
"numGc": 4,
"goroutines": 515,
"connections": 512,
"accepted": 512,
"fileDescriptors": 519
},
"after": {
"heapAlloc": 4233984,
"heapInuse": 6316032,
"stackInuse": 458752,
"totalAlloc": 8182368,
"mallocs": 60013,
"numGc": 5,
"goroutines": 3,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1804784,
"heapInuse": 3686400,
"stackInuse": 458752,
"totalAlloc": 8193144,
"mallocs": 60042,
"numGc": 6,
"goroutines": 3,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
}
}
]
}

View File

@@ -0,0 +1,265 @@
{
"createdAt": "2026-09-21T17:27:47Z",
"goVersion": "go1.25.14",
"platform": "linux/arm64",
"gomaxprocs": 4,
"duration": "30s",
"tls": true,
"notes": [
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with \u003c=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"FileDescriptors is read from /proc/self/fd on Linux and omitted on other platforms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection."
],
"results": [
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 4442303,
"errors": 0,
"elapsedSeconds": 30.001232972,
"requestsPerSecond": 148070.68109987277,
"p50Millis": 0.589824,
"p95Millis": 2.62144,
"p99Millis": 3.670016,
"serverBytesPerRequest": 4475.325703807237,
"serverAllocsPerRequest": 49.07248492504901,
"sampledPeakHeapAlloc": 7609472,
"sampledPeakFileDescriptors": 135,
"before": {
"heapAlloc": 3340488,
"heapInuse": 5988352,
"stackInuse": 2195456,
"totalAlloc": 9430136,
"mallocs": 83639,
"numGc": 5,
"goroutines": 124,
"connections": 122,
"accepted": 122,
"fileDescriptors": 135
},
"during": {
"heapAlloc": 6485192,
"heapInuse": 8716288,
"stackInuse": 1998848,
"totalAlloc": 19890182936,
"mallocs": 218078486,
"numGc": 5603,
"goroutines": 130,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"after": {
"heapAlloc": 2853976,
"heapInuse": 5251072,
"stackInuse": 884736,
"totalAlloc": 19890213312,
"mallocs": 218078820,
"numGc": 5604,
"goroutines": 2,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1745496,
"heapInuse": 3686400,
"stackInuse": 884736,
"totalAlloc": 19890215312,
"mallocs": 218078842,
"numGc": 5605,
"goroutines": 2,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 4491731,
"errors": 0,
"elapsedSeconds": 30.000919805,
"requestsPerSecond": 149719.77623337405,
"p50Millis": 0.622592,
"p95Millis": 2.62144,
"p99Millis": 3.538944,
"serverBytesPerRequest": 4807.806184297323,
"serverAllocsPerRequest": 61.06011045630293,
"sampledPeakHeapAlloc": 5859960,
"sampledPeakFileDescriptors": 135,
"before": {
"heapAlloc": 2685024,
"heapInuse": 5136384,
"stackInuse": 2621440,
"totalAlloc": 8854080,
"mallocs": 83570,
"numGc": 5,
"goroutines": 125,
"connections": 122,
"accepted": 122,
"fileDescriptors": 135
},
"during": {
"heapAlloc": 3345480,
"heapInuse": 5980160,
"stackInuse": 1605632,
"totalAlloc": 21604226160,
"mallocs": 274349161,
"numGc": 7802,
"goroutines": 131,
"connections": 128,
"accepted": 128,
"fileDescriptors": 135
},
"after": {
"heapAlloc": 2764288,
"heapInuse": 5341184,
"stackInuse": 1605632,
"totalAlloc": 21604248208,
"mallocs": 274349494,
"numGc": 7803,
"goroutines": 3,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1654056,
"heapInuse": 3506176,
"stackInuse": 1605632,
"totalAlloc": 21604258680,
"mallocs": 274349519,
"numGc": 7804,
"goroutines": 3,
"connections": 0,
"accepted": 128,
"fileDescriptors": 7
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 7274360,
"idleHeapPerConnection": 14207.734375,
"before": {
"heapAlloc": 1515704,
"heapInuse": 3301376,
"stackInuse": 425984,
"totalAlloc": 3023896,
"mallocs": 23663,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0,
"fileDescriptors": 7
},
"during": {
"heapAlloc": 8790064,
"heapInuse": 12066816,
"stackInuse": 4718592,
"totalAlloc": 29570320,
"mallocs": 272972,
"numGc": 9,
"goroutines": 514,
"connections": 512,
"accepted": 512,
"fileDescriptors": 519
},
"after": {
"heapAlloc": 6138608,
"heapInuse": 8765440,
"stackInuse": 458752,
"totalAlloc": 29675376,
"mallocs": 274141,
"numGc": 10,
"goroutines": 2,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1820824,
"heapInuse": 3825664,
"stackInuse": 458752,
"totalAlloc": 29677376,
"mallocs": 274163,
"numGc": 11,
"goroutines": 2,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 4689576,
"idleHeapPerConnection": 9159.328125,
"before": {
"heapAlloc": 1520864,
"heapInuse": 3284992,
"stackInuse": 458752,
"totalAlloc": 3029160,
"mallocs": 23661,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0,
"fileDescriptors": 7
},
"during": {
"heapAlloc": 6210440,
"heapInuse": 9314304,
"stackInuse": 8880128,
"totalAlloc": 27150344,
"mallocs": 271905,
"numGc": 10,
"goroutines": 515,
"connections": 512,
"accepted": 512,
"fileDescriptors": 519
},
"after": {
"heapAlloc": 6230688,
"heapInuse": 9330688,
"stackInuse": 491520,
"totalAlloc": 27245184,
"mallocs": 273078,
"numGc": 11,
"goroutines": 3,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
},
"afterReclaim": {
"heapAlloc": 1817296,
"heapInuse": 3702784,
"stackInuse": 491520,
"totalAlloc": 27255960,
"mallocs": 273107,
"numGc": 12,
"goroutines": 3,
"connections": 0,
"accepted": 512,
"fileDescriptors": 7
}
}
]
}

1735
performance/tcpBaseline.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,592 @@
{
"createdAt": "2026-09-20T21:27:15Z",
"goVersion": "go1.25.0",
"platform": "darwin/arm64",
"gomaxprocs": 12,
"duration": "5s",
"tls": true,
"notes": [
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with \u003c=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection."
],
"results": [
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 1,
"run": 1,
"requests": 86935,
"errors": 0,
"elapsedSeconds": 5.000022,
"requestsPerSecond": 17386.92349753661,
"p50Millis": 0.0512,
"p95Millis": 0.1024,
"p99Millis": 0.180224,
"serverBytesPerRequest": 4514.537988152068,
"serverAllocsPerRequest": 49.1346753321447,
"sampledPeakHeapAlloc": 3349712,
"before": {
"heapAlloc": 1678936,
"heapInuse": 3661824,
"stackInuse": 655360,
"totalAlloc": 3167032,
"mallocs": 24353,
"numGc": 2,
"goroutines": 3,
"connections": 1,
"accepted": 1
},
"during": {
"heapAlloc": 3337216,
"heapInuse": 5341184,
"stackInuse": 753664,
"totalAlloc": 395638392,
"mallocs": 4295876,
"numGc": 238,
"goroutines": 3,
"connections": 1,
"accepted": 1
},
"after": {
"heapAlloc": 1774168,
"heapInuse": 3792896,
"stackInuse": 688128,
"totalAlloc": 395642616,
"mallocs": 4295888,
"numGc": 239,
"goroutines": 2,
"connections": 0,
"accepted": 1
},
"afterReclaim": {
"heapAlloc": 1741008,
"heapInuse": 3620864,
"stackInuse": 688128,
"totalAlloc": 395644800,
"mallocs": 4295894,
"numGc": 240,
"goroutines": 2,
"connections": 0,
"accepted": 1
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 1,
"run": 1,
"requests": 91798,
"errors": 0,
"elapsedSeconds": 5.000019125,
"requestsPerSecond": 18359.52977479861,
"p50Millis": 0.049152,
"p95Millis": 0.090112,
"p99Millis": 0.16384,
"serverBytesPerRequest": 4635.560426153075,
"serverAllocsPerRequest": 60.11798731998518,
"sampledPeakHeapAlloc": 3267488,
"before": {
"heapAlloc": 1653880,
"heapInuse": 3678208,
"stackInuse": 622592,
"totalAlloc": 3145792,
"mallocs": 24330,
"numGc": 2,
"goroutines": 4,
"connections": 1,
"accepted": 1
},
"during": {
"heapAlloc": 1828360,
"heapInuse": 4022272,
"stackInuse": 688128,
"totalAlloc": 428680968,
"mallocs": 5543041,
"numGc": 264,
"goroutines": 4,
"connections": 1,
"accepted": 1
},
"after": {
"heapAlloc": 1687208,
"heapInuse": 3817472,
"stackInuse": 655360,
"totalAlloc": 428685256,
"mallocs": 5543054,
"numGc": 265,
"goroutines": 3,
"connections": 0,
"accepted": 1
},
"afterReclaim": {
"heapAlloc": 1668072,
"heapInuse": 3588096,
"stackInuse": 655360,
"totalAlloc": 428687440,
"mallocs": 5543060,
"numGc": 266,
"goroutines": 3,
"connections": 0,
"accepted": 1
}
},
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 32,
"run": 1,
"requests": 443035,
"errors": 0,
"elapsedSeconds": 5.000301084,
"requestsPerSecond": 88601.66469127762,
"p50Millis": 0.278528,
"p95Millis": 1.015808,
"p99Millis": 1.507328,
"serverBytesPerRequest": 4524.947022244292,
"serverAllocsPerRequest": 49.153904319071856,
"sampledPeakHeapAlloc": 4957328,
"before": {
"heapAlloc": 2174976,
"heapInuse": 5406720,
"stackInuse": 1277952,
"totalAlloc": 4859520,
"mallocs": 39778,
"numGc": 4,
"goroutines": 34,
"connections": 32,
"accepted": 32
},
"during": {
"heapAlloc": 3437936,
"heapInuse": 6717440,
"stackInuse": 2490368,
"totalAlloc": 2009569424,
"mallocs": 21816678,
"numGc": 1167,
"goroutines": 34,
"connections": 32,
"accepted": 32
},
"after": {
"heapAlloc": 2301792,
"heapInuse": 4833280,
"stackInuse": 2195456,
"totalAlloc": 2009575408,
"mallocs": 21816709,
"numGc": 1168,
"goroutines": 2,
"connections": 0,
"accepted": 32
},
"afterReclaim": {
"heapAlloc": 1983832,
"heapInuse": 3915776,
"stackInuse": 2195456,
"totalAlloc": 2009577336,
"mallocs": 21816714,
"numGc": 1169,
"goroutines": 2,
"connections": 0,
"accepted": 32
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 32,
"run": 1,
"requests": 422203,
"errors": 0,
"elapsedSeconds": 5.0001875,
"requestsPerSecond": 84437.43359624014,
"p50Millis": 0.294912,
"p95Millis": 1.015808,
"p99Millis": 1.507328,
"serverBytesPerRequest": 4655.5327176737255,
"serverAllocsPerRequest": 60.24485614739829,
"sampledPeakHeapAlloc": 3953128,
"before": {
"heapAlloc": 1991704,
"heapInuse": 5013504,
"stackInuse": 1081344,
"totalAlloc": 4644928,
"mallocs": 39578,
"numGc": 3,
"goroutines": 35,
"connections": 32,
"accepted": 32
},
"during": {
"heapAlloc": 2969168,
"heapInuse": 6266880,
"stackInuse": 1212416,
"totalAlloc": 1970224808,
"mallocs": 25475137,
"numGc": 1687,
"goroutines": 35,
"connections": 32,
"accepted": 32
},
"after": {
"heapAlloc": 2053344,
"heapInuse": 5062656,
"stackInuse": 851968,
"totalAlloc": 1970231512,
"mallocs": 25475175,
"numGc": 1688,
"goroutines": 3,
"connections": 0,
"accepted": 32
},
"afterReclaim": {
"heapAlloc": 1754136,
"heapInuse": 3792896,
"stackInuse": 851968,
"totalAlloc": 1970233696,
"mallocs": 25475181,
"numGc": 1689,
"goroutines": 3,
"connections": 0,
"accepted": 32
}
},
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 564759,
"errors": 0,
"elapsedSeconds": 5.000520667,
"requestsPerSecond": 112940.03916972512,
"p50Millis": 0.8192,
"p95Millis": 3.2768,
"p99Millis": 4.718592,
"serverBytesPerRequest": 4499.995148373023,
"serverAllocsPerRequest": 49.13783401415471,
"sampledPeakHeapAlloc": 7449240,
"before": {
"heapAlloc": 3409760,
"heapInuse": 6938624,
"stackInuse": 2457600,
"totalAlloc": 9389504,
"mallocs": 81651,
"numGc": 8,
"goroutines": 118,
"connections": 116,
"accepted": 116
},
"during": {
"heapAlloc": 5261072,
"heapInuse": 9068544,
"stackInuse": 3276800,
"totalAlloc": 2550802264,
"mallocs": 27832685,
"numGc": 775,
"goroutines": 130,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 3142160,
"heapInuse": 6086656,
"stackInuse": 2162688,
"totalAlloc": 2550814744,
"mallocs": 27832752,
"numGc": 776,
"goroutines": 2,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 2014424,
"heapInuse": 4038656,
"stackInuse": 2162688,
"totalAlloc": 2550816928,
"mallocs": 27832758,
"numGc": 777,
"goroutines": 2,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 571218,
"errors": 0,
"elapsedSeconds": 5.000520459,
"requestsPerSecond": 114231.70941574985,
"p50Millis": 0.720896,
"p95Millis": 3.538944,
"p99Millis": 5.24288,
"serverBytesPerRequest": 4630.001169430935,
"serverAllocsPerRequest": 60.118471406713375,
"sampledPeakHeapAlloc": 6594168,
"before": {
"heapAlloc": 2900712,
"heapInuse": 6209536,
"stackInuse": 1966080,
"totalAlloc": 9196776,
"mallocs": 86400,
"numGc": 6,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"during": {
"heapAlloc": 5161328,
"heapInuse": 8462336,
"stackInuse": 1998848,
"totalAlloc": 2653936784,
"mallocs": 34427153,
"numGc": 1026,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2950048,
"heapInuse": 6201344,
"stackInuse": 851968,
"totalAlloc": 2653948208,
"mallocs": 34427217,
"numGc": 1027,
"goroutines": 3,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1824888,
"heapInuse": 3809280,
"stackInuse": 851968,
"totalAlloc": 2653950392,
"mallocs": 34427223,
"numGc": 1028,
"goroutines": 3,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 128,
"run": 1,
"errors": 0,
"idleHeapDelta": 1857928,
"idleHeapPerConnection": 14515.0625,
"before": {
"heapAlloc": 1632120,
"heapInuse": 3538944,
"stackInuse": 524288,
"totalAlloc": 3074624,
"mallocs": 23744,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 3490048,
"heapInuse": 5808128,
"stackInuse": 1835008,
"totalAlloc": 9802696,
"mallocs": 86273,
"numGc": 5,
"goroutines": 130,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2814128,
"heapInuse": 5062656,
"stackInuse": 720896,
"totalAlloc": 9818288,
"mallocs": 86380,
"numGc": 6,
"goroutines": 2,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1732536,
"heapInuse": 3661824,
"stackInuse": 720896,
"totalAlloc": 9820216,
"mallocs": 86385,
"numGc": 7,
"goroutines": 2,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 128,
"run": 1,
"errors": 0,
"idleHeapDelta": 1212976,
"idleHeapPerConnection": 9476.375,
"before": {
"heapAlloc": 1626208,
"heapInuse": 3448832,
"stackInuse": 491520,
"totalAlloc": 3068760,
"mallocs": 23727,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 2839184,
"heapInuse": 5210112,
"stackInuse": 1802240,
"totalAlloc": 9147832,
"mallocs": 85868,
"numGc": 5,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2838168,
"heapInuse": 5169152,
"stackInuse": 688128,
"totalAlloc": 9163568,
"mallocs": 85970,
"numGc": 6,
"goroutines": 3,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1729792,
"heapInuse": 3563520,
"stackInuse": 688128,
"totalAlloc": 9166056,
"mallocs": 85980,
"numGc": 7,
"goroutines": 3,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 1024,
"run": 1,
"errors": 0,
"idleHeapDelta": 14527832,
"idleHeapPerConnection": 14187.3359375,
"before": {
"heapAlloc": 1626760,
"heapInuse": 3497984,
"stackInuse": 491520,
"totalAlloc": 3069240,
"mallocs": 23740,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 16154592,
"heapInuse": 20807680,
"stackInuse": 9207808,
"totalAlloc": 56229808,
"mallocs": 522226,
"numGc": 12,
"goroutines": 1026,
"connections": 1024,
"accepted": 1024
},
"after": {
"heapAlloc": 10815896,
"heapInuse": 14376960,
"stackInuse": 720896,
"totalAlloc": 56293624,
"mallocs": 522449,
"numGc": 13,
"goroutines": 2,
"connections": 0,
"accepted": 1024
},
"afterReclaim": {
"heapAlloc": 2202576,
"heapInuse": 4227072,
"stackInuse": 720896,
"totalAlloc": 56296208,
"mallocs": 522460,
"numGc": 14,
"goroutines": 2,
"connections": 0,
"accepted": 1024
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 1024,
"run": 1,
"errors": 0,
"idleHeapDelta": 9372528,
"idleHeapPerConnection": 9152.859375,
"before": {
"heapAlloc": 1625904,
"heapInuse": 3457024,
"stackInuse": 491520,
"totalAlloc": 3068472,
"mallocs": 23724,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 10998432,
"heapInuse": 14934016,
"stackInuse": 9240576,
"totalAlloc": 51165352,
"mallocs": 519121,
"numGc": 13,
"goroutines": 1027,
"connections": 1024,
"accepted": 1024
},
"after": {
"heapAlloc": 11019640,
"heapInuse": 15032320,
"stackInuse": 6553600,
"totalAlloc": 51226512,
"mallocs": 519340,
"numGc": 14,
"goroutines": 3,
"connections": 0,
"accepted": 1024
},
"afterReclaim": {
"heapAlloc": 2196104,
"heapInuse": 4153344,
"stackInuse": 6553600,
"totalAlloc": 51229000,
"mallocs": 519350,
"numGc": 15,
"goroutines": 3,
"connections": 0,
"accepted": 1024
}
}
]
}

View File

@@ -0,0 +1,592 @@
{
"createdAt": "2026-09-21T17:11:34Z",
"goVersion": "go1.25.14",
"platform": "linux/arm64",
"gomaxprocs": 4,
"duration": "3s",
"tls": true,
"notes": [
"Loopback HTTP/1.1 health response, fixed requestId, optional TLS, no access logs or database.",
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
"Latency percentiles include failures and are histogram upper bounds with \u003c=6.25% rounding.",
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection."
],
"results": [
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 1,
"run": 1,
"requests": 36326,
"errors": 0,
"elapsedSeconds": 3.000028418,
"requestsPerSecond": 12108.551966390074,
"p50Millis": 0.08192,
"p95Millis": 0.1024,
"p99Millis": 0.139264,
"serverBytesPerRequest": 4475.917854979904,
"serverAllocsPerRequest": 49.04346748885096,
"sampledPeakHeapAlloc": 3669264,
"before": {
"heapAlloc": 1555696,
"heapInuse": 3522560,
"stackInuse": 491520,
"totalAlloc": 3107240,
"mallocs": 24268,
"numGc": 2,
"goroutines": 3,
"connections": 1,
"accepted": 1
},
"during": {
"heapAlloc": 1789280,
"heapInuse": 3874816,
"stackInuse": 819200,
"totalAlloc": 165699432,
"mallocs": 1805821,
"numGc": 82,
"goroutines": 3,
"connections": 1,
"accepted": 1
},
"after": {
"heapAlloc": 1645816,
"heapInuse": 3629056,
"stackInuse": 753664,
"totalAlloc": 165700968,
"mallocs": 1805830,
"numGc": 83,
"goroutines": 2,
"connections": 0,
"accepted": 1
},
"afterReclaim": {
"heapAlloc": 1625904,
"heapInuse": 3522560,
"stackInuse": 720896,
"totalAlloc": 165701872,
"mallocs": 1805836,
"numGc": 84,
"goroutines": 2,
"connections": 0,
"accepted": 1
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 1,
"run": 1,
"requests": 36410,
"errors": 0,
"elapsedSeconds": 3.000096335,
"requestsPerSecond": 12136.276950586656,
"p50Millis": 0.08192,
"p95Millis": 0.1024,
"p99Millis": 0.131072,
"serverBytesPerRequest": 4807.387201318319,
"serverAllocsPerRequest": 61.041444658060975,
"sampledPeakHeapAlloc": 3473824,
"before": {
"heapAlloc": 1546544,
"heapInuse": 3563520,
"stackInuse": 524288,
"totalAlloc": 3099040,
"mallocs": 24253,
"numGc": 2,
"goroutines": 4,
"connections": 1,
"accepted": 1
},
"during": {
"heapAlloc": 1987016,
"heapInuse": 4014080,
"stackInuse": 524288,
"totalAlloc": 178136008,
"mallocs": 2246772,
"numGc": 96,
"goroutines": 4,
"connections": 1,
"accepted": 1
},
"after": {
"heapAlloc": 1557080,
"heapInuse": 3637248,
"stackInuse": 425984,
"totalAlloc": 178137480,
"mallocs": 2246784,
"numGc": 97,
"goroutines": 3,
"connections": 0,
"accepted": 1
},
"afterReclaim": {
"heapAlloc": 1543112,
"heapInuse": 3473408,
"stackInuse": 425984,
"totalAlloc": 178138128,
"mallocs": 2246789,
"numGc": 98,
"goroutines": 3,
"connections": 0,
"accepted": 1
}
},
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 32,
"run": 1,
"requests": 297651,
"errors": 0,
"elapsedSeconds": 3.000748959,
"requestsPerSecond": 99192.23636061586,
"p50Millis": 0.172032,
"p95Millis": 1.179648,
"p99Millis": 2.097152,
"serverBytesPerRequest": 4478.059122932562,
"serverAllocsPerRequest": 49.0660605877353,
"sampledPeakHeapAlloc": 4147936,
"before": {
"heapAlloc": 2046152,
"heapInuse": 4554752,
"stackInuse": 884736,
"totalAlloc": 4736656,
"mallocs": 39461,
"numGc": 3,
"goroutines": 34,
"connections": 32,
"accepted": 32
},
"during": {
"heapAlloc": 3132976,
"heapInuse": 5529600,
"stackInuse": 1212416,
"totalAlloc": 1337635432,
"mallocs": 14644023,
"numGc": 642,
"goroutines": 34,
"connections": 32,
"accepted": 32
},
"after": {
"heapAlloc": 1946392,
"heapInuse": 4136960,
"stackInuse": 884736,
"totalAlloc": 1337638376,
"mallocs": 14644047,
"numGc": 643,
"goroutines": 2,
"connections": 0,
"accepted": 32
},
"afterReclaim": {
"heapAlloc": 1657656,
"heapInuse": 3596288,
"stackInuse": 884736,
"totalAlloc": 1337639280,
"mallocs": 14644053,
"numGc": 644,
"goroutines": 2,
"connections": 0,
"accepted": 32
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 32,
"run": 1,
"requests": 271586,
"errors": 0,
"elapsedSeconds": 3.008536543,
"requestsPerSecond": 90271.79697448004,
"p50Millis": 0.172032,
"p95Millis": 1.114112,
"p99Millis": 2.097152,
"serverBytesPerRequest": 4810.125264188876,
"serverAllocsPerRequest": 61.068320900193676,
"sampledPeakHeapAlloc": 3706424,
"before": {
"heapAlloc": 1833336,
"heapInuse": 4136960,
"stackInuse": 1048576,
"totalAlloc": 4566744,
"mallocs": 39398,
"numGc": 3,
"goroutines": 35,
"connections": 32,
"accepted": 32
},
"during": {
"heapAlloc": 2032480,
"heapInuse": 4661248,
"stackInuse": 851968,
"totalAlloc": 1310929424,
"mallocs": 16624699,
"numGc": 742,
"goroutines": 35,
"connections": 32,
"accepted": 32
},
"after": {
"heapAlloc": 1875360,
"heapInuse": 4186112,
"stackInuse": 524288,
"totalAlloc": 1310933392,
"mallocs": 16624734,
"numGc": 743,
"goroutines": 3,
"connections": 0,
"accepted": 32
},
"afterReclaim": {
"heapAlloc": 1593792,
"heapInuse": 3465216,
"stackInuse": 524288,
"totalAlloc": 1310934296,
"mallocs": 16624740,
"numGc": 744,
"goroutines": 3,
"connections": 0,
"accepted": 32
}
},
{
"adapter": "nethttp",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 322494,
"errors": 0,
"elapsedSeconds": 3.001132293,
"requestsPerSecond": 107457.44223012164,
"p50Millis": 0.589824,
"p95Millis": 2.752512,
"p99Millis": 25.165824,
"serverBytesPerRequest": 4475.7173528809835,
"serverAllocsPerRequest": 49.07075790557344,
"sampledPeakHeapAlloc": 7137248,
"before": {
"heapAlloc": 3503480,
"heapInuse": 6340608,
"stackInuse": 2228224,
"totalAlloc": 9426048,
"mallocs": 83598,
"numGc": 5,
"goroutines": 124,
"connections": 122,
"accepted": 122
},
"during": {
"heapAlloc": 6849936,
"heapInuse": 9125888,
"stackInuse": 2162688,
"totalAlloc": 1452818040,
"mallocs": 15908623,
"numGc": 415,
"goroutines": 130,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2816896,
"heapInuse": 5300224,
"stackInuse": 1048576,
"totalAlloc": 1452825560,
"mallocs": 15908667,
"numGc": 416,
"goroutines": 2,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1717496,
"heapInuse": 3710976,
"stackInuse": 1015808,
"totalAlloc": 1452826464,
"mallocs": 15908673,
"numGc": 417,
"goroutines": 2,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "native",
"scenario": "traffic",
"concurrency": 128,
"run": 1,
"requests": 299953,
"errors": 0,
"elapsedSeconds": 3.003243168,
"requestsPerSecond": 99876.36139359062,
"p50Millis": 0.622592,
"p95Millis": 2.752512,
"p99Millis": 32.505856,
"serverBytesPerRequest": 4817.3341823552355,
"serverAllocsPerRequest": 61.15272726060416,
"sampledPeakHeapAlloc": 5668056,
"before": {
"heapAlloc": 2118392,
"heapInuse": 4661248,
"stackInuse": 1605632,
"totalAlloc": 6143952,
"mallocs": 56351,
"numGc": 4,
"goroutines": 61,
"connections": 58,
"accepted": 58
},
"during": {
"heapAlloc": 3375320,
"heapInuse": 5996544,
"stackInuse": 1802240,
"totalAlloc": 1451117792,
"mallocs": 18399295,
"numGc": 528,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2766336,
"heapInuse": 5382144,
"stackInuse": 1769472,
"totalAlloc": 1451125632,
"mallocs": 18399341,
"numGc": 529,
"goroutines": 3,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1658104,
"heapInuse": 3588096,
"stackInuse": 1769472,
"totalAlloc": 1451126280,
"mallocs": 18399346,
"numGc": 530,
"goroutines": 3,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 128,
"run": 1,
"errors": 0,
"idleHeapDelta": 1838600,
"idleHeapPerConnection": 14364.0625,
"before": {
"heapAlloc": 1521656,
"heapInuse": 3260416,
"stackInuse": 458752,
"totalAlloc": 3029904,
"mallocs": 23674,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 3360256,
"heapInuse": 5718016,
"stackInuse": 1540096,
"totalAlloc": 9686296,
"mallocs": 86122,
"numGc": 5,
"goroutines": 130,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2682728,
"heapInuse": 4792320,
"stackInuse": 425984,
"totalAlloc": 9695376,
"mallocs": 86176,
"numGc": 6,
"goroutines": 2,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1604552,
"heapInuse": 3440640,
"stackInuse": 425984,
"totalAlloc": 9696584,
"mallocs": 86186,
"numGc": 7,
"goroutines": 2,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 128,
"run": 1,
"errors": 0,
"idleHeapDelta": 1191856,
"idleHeapPerConnection": 9311.375,
"before": {
"heapAlloc": 1521040,
"heapInuse": 3325952,
"stackInuse": 458752,
"totalAlloc": 3029464,
"mallocs": 23663,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 2712896,
"heapInuse": 4939776,
"stackInuse": 2555904,
"totalAlloc": 9083760,
"mallocs": 85842,
"numGc": 5,
"goroutines": 131,
"connections": 128,
"accepted": 128
},
"after": {
"heapAlloc": 2711312,
"heapInuse": 4907008,
"stackInuse": 425984,
"totalAlloc": 9091576,
"mallocs": 85898,
"numGc": 6,
"goroutines": 3,
"connections": 0,
"accepted": 128
},
"afterReclaim": {
"heapAlloc": 1608416,
"heapInuse": 3416064,
"stackInuse": 425984,
"totalAlloc": 9092784,
"mallocs": 85908,
"numGc": 7,
"goroutines": 3,
"connections": 0,
"accepted": 128
}
},
{
"adapter": "nethttp",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 7269176,
"idleHeapPerConnection": 14197.609375,
"before": {
"heapAlloc": 1521080,
"heapInuse": 3383296,
"stackInuse": 458752,
"totalAlloc": 3029480,
"mallocs": 23669,
"numGc": 1,
"goroutines": 2,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 8790256,
"heapInuse": 11960320,
"stackInuse": 4718592,
"totalAlloc": 29547624,
"mallocs": 272853,
"numGc": 9,
"goroutines": 514,
"connections": 512,
"accepted": 512
},
"after": {
"heapAlloc": 6121872,
"heapInuse": 8749056,
"stackInuse": 458752,
"totalAlloc": 29580224,
"mallocs": 272941,
"numGc": 10,
"goroutines": 2,
"connections": 0,
"accepted": 512
},
"afterReclaim": {
"heapAlloc": 1812232,
"heapInuse": 3817472,
"stackInuse": 458752,
"totalAlloc": 29581432,
"mallocs": 272951,
"numGc": 11,
"goroutines": 2,
"connections": 0,
"accepted": 512
}
},
{
"adapter": "native",
"scenario": "idle",
"concurrency": 512,
"run": 1,
"errors": 0,
"idleHeapDelta": 4687096,
"idleHeapPerConnection": 9154.484375,
"before": {
"heapAlloc": 1520480,
"heapInuse": 3293184,
"stackInuse": 458752,
"totalAlloc": 3028752,
"mallocs": 23658,
"numGc": 1,
"goroutines": 3,
"connections": 0,
"accepted": 0
},
"during": {
"heapAlloc": 6207576,
"heapInuse": 9035776,
"stackInuse": 8880128,
"totalAlloc": 27129544,
"mallocs": 271820,
"numGc": 9,
"goroutines": 515,
"connections": 512,
"accepted": 512
},
"after": {
"heapAlloc": 6228280,
"heapInuse": 9027584,
"stackInuse": 491520,
"totalAlloc": 27168240,
"mallocs": 271921,
"numGc": 10,
"goroutines": 3,
"connections": 0,
"accepted": 512
},
"afterReclaim": {
"heapAlloc": 1812240,
"heapInuse": 3645440,
"stackInuse": 491520,
"totalAlloc": 27169448,
"mallocs": 271931,
"numGc": 11,
"goroutines": 3,
"connections": 0,
"accepted": 512
}
}
]
}

View File

@@ -25,7 +25,7 @@ def free_address():
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go") parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.4.0", help="Target framework release") parser.add_argument("--version", default="v0.6.0-preview", help="Target framework release")
parser.add_argument("--from-version", default="v0.2.0", help="Version used before upgrade") parser.add_argument("--from-version", default="v0.2.0", help="Version used before upgrade")
args = parser.parse_args() args = parser.parse_args()

View File

@@ -145,7 +145,7 @@ class Server:
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go") parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.4.0") parser.add_argument("--version", default="v0.6.0-preview")
parser.add_argument("--cli", help="Use a locally built CLI") parser.add_argument("--cli", help="Use a locally built CLI")
parser.add_argument("--framework-dir", help="Local framework source for pre-release checks") parser.add_argument("--framework-dir", help="Local framework source for pre-release checks")
parser.add_argument("--image", default="mysql:8.4") parser.add_argument("--image", default="mysql:8.4")

84
scripts/verifyLinux.py Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Run GoOmni's repeatable Linux checks inside a pinned Go Docker image."""
import argparse
from pathlib import Path
import shutil
import shlex
import subprocess
import sys
def workspace_output(root: Path, value: str) -> tuple[Path, str]:
requested = Path(value)
host_path = requested if requested.is_absolute() else root / requested
host_path = host_path.resolve()
try:
relative = host_path.relative_to(root)
except ValueError as exc:
raise SystemExit("--output must be inside the repository") from exc
host_path.parent.mkdir(parents=True, exist_ok=True)
return host_path, "/workspace/" + relative.as_posix()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--image", default="golang:1.25", help="Docker image (default: golang:1.25)")
parser.add_argument("--gomaxprocs", type=int, default=4)
parser.add_argument("--load", action="store_true", help="also run the isolated server matrix")
parser.add_argument("--tls", action="store_true", help="run the load matrix over TLS")
parser.add_argument("--duration", default="2s", help="duration per traffic case (up to 30m)")
parser.add_argument("--count", type=int, default=3)
parser.add_argument("--concurrency", default="1,32,128")
parser.add_argument("--idle", default="128,1024")
parser.add_argument("--output", help="JSON report path inside the repository")
args = parser.parse_args()
if args.gomaxprocs < 1 or args.count < 1:
parser.error("--gomaxprocs and --count must be positive")
if args.tls and not args.load:
parser.error("--tls requires --load")
root = Path(__file__).resolve().parent.parent
docker = shutil.which("docker")
if docker is None:
raise SystemExit("docker is required for Linux verification")
commands = [
"go test -race ./...",
"go vet ./...",
"go build ./...",
]
if args.load:
load = [
"go", "run", "./internal/perfcheck",
"--duration", args.duration,
"--count", str(args.count),
"--concurrency", args.concurrency,
"--idle", args.idle,
]
if args.tls:
load.append("--tls")
if args.output:
_, container_path = workspace_output(root, args.output)
load += ["--output", container_path]
commands.append(" ".join(shlex.quote(part) for part in load))
command = " && ".join(commands)
docker_args = [
docker, "run", "--rm", "--pull=missing",
"-v", f"{root}:/workspace",
"-w", "/workspace",
"-e", "GOWORK=off",
"-e", f"GOMAXPROCS={args.gomaxprocs}",
args.image, "sh", "-c", command,
]
print(f"Linux verification image: {args.image}", flush=True)
print(f"Linux verification command: {command}", flush=True)
result = subprocess.run(docker_args, cwd=root)
if result.returncode != 0:
raise SystemExit(f"Linux verification failed with exit code {result.returncode}")
print("PASS Linux verification", flush=True)
if __name__ == "__main__":
main()

View File

@@ -11,7 +11,15 @@ def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go") parser.add_argument("--go", default="go")
parser.add_argument("--count", type=int, default=3) parser.add_argument("--count", type=int, default=3)
parser.add_argument("--load", action="store_true", help="Also compare isolated servers and idle connection memory")
parser.add_argument("--tls", action="store_true", help="Run the isolated load matrix over TLS")
parser.add_argument("--duration", default="2s", help="Duration per load scenario")
parser.add_argument("--concurrency", default="1,32,128")
parser.add_argument("--idle", default="128,1024")
parser.add_argument("--output", help="JSON output path for --load")
args = parser.parse_args() args = parser.parse_args()
if args.count < 1:
parser.error("--count must be positive")
root = Path(__file__).resolve().parent.parent root = Path(__file__).resolve().parent.parent
env = dict(os.environ, GOWORK="off") env = dict(os.environ, GOWORK="off")
checks = [ checks = [
@@ -24,6 +32,15 @@ def main():
"transport benchmarks", "transport benchmarks",
), ),
] ]
if args.load:
command = [args.go, "run", "./internal/perfcheck", "--duration", args.duration,
"--count", str(args.count), "--concurrency", args.concurrency,
"--idle", args.idle]
if args.tls:
command.append("--tls")
if args.output:
command += ["--output", str(Path(args.output).resolve())]
checks.append((command, "isolated server concurrency and idle-memory matrix"))
for command, name in checks: for command, name in checks:
print(f"Running {name}", flush=True) print(f"Running {name}", flush=True)
result = subprocess.run(command, cwd=root, env=env, text=True, result = subprocess.run(command, cwd=root, env=env, text=True,

View File

@@ -91,7 +91,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go", help="Go executable") parser.add_argument("--go", default="go", help="Go executable")
parser.add_argument("--version", default="v0.4.0", help="Released CLI version to install") parser.add_argument("--version", default="v0.6.0-preview", help="Released CLI version to install")
parser.add_argument("--cli", help="Use a locally built CLI instead of installing") parser.add_argument("--cli", help="Use a locally built CLI instead of installing")
parser.add_argument("--framework-version", help="Explicit version passed to the CLI") parser.add_argument("--framework-version", help="Explicit version passed to the CLI")
parser.add_argument("--framework-dir", help="Local checkout for pre-release verification") parser.add_argument("--framework-dir", help="Local checkout for pre-release verification")

View File

@@ -13,7 +13,7 @@ from verifyScaffold import MODULE, run
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go") parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.4.0") parser.add_argument("--version", default="v0.6.0-preview")
parser.add_argument("--framework-dir", help="Use a local checkout before release") parser.add_argument("--framework-dir", help="Use a local checkout before release")
args = parser.parse_args() args = parser.parse_args()
env = dict(os.environ, GOWORK="off") env = dict(os.environ, GOWORK="off")
@@ -55,7 +55,7 @@ func TestContract(t *testing.T) {{
count = sum(event.get("Action") == "pass" and event.get("Test", "").startswith(name + "/") count = sum(event.get("Action") == "pass" and event.get("Test", "").startswith(name + "/")
for event in events) for event in events)
print(f"PASS {adapter}: {count} contract checks, including business handlers, cancellation and concurrent requests", flush=True) print(f"PASS {adapter}: {count} contract checks, including business handlers, cancellation and concurrent requests", flush=True)
print("PASS independent module: both adapters accepted by the same public contract suite", flush=True) print("PASS independent module: nethttp accepted by the public contract suite", flush=True)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -14,6 +14,42 @@ registerRoutes(application) // 与 omni.New() 复用同一份业务注册代码
`transport/nethttp` 是当前内置的标准库路由实现。它提供一套可移植的路径、方法和错误回退规则;自研网络层可以直接实现同一个 `httpx.Router` 契约。 `transport/nethttp` 是当前内置的标准库路由实现。它提供一套可移植的路径、方法和错误回退规则;自研网络层可以直接实现同一个 `httpx.Router` 契约。
`transport/native` 是第一版自研 HTTP/1.1 服务端:它自己管理 TCP listener、连接、Keep-Alive、请求解析、响应写回和优雅关闭,再把请求交给现有 `http.Handler`。请求体支持 `Content-Length` 和 chunked,并统一受 `MaxBodyBytes` 限制;其他传输编码会被拒绝。它仍使用标准 `http.Request` / `http.ResponseWriter` 作为业务桥接,因此不会影响现有 Handler / Service。
业务 Handler 需要流式返回时可以断言 `http.Flusher` 并调用 `Flush()`;HTTP/1.1 会自动使用 chunked 响应,普通未 Flush 的响应仍自动生成 `Content-Length`。HTTP/1.0 没有 chunked framing,会退回到写完后关闭连接。
Handler 运行超过约 5 ms 时,native 启动一次阻塞读取监测;读到 EOF 或连接错误会取消 `Request.Context()`,流式写入失败也会触发取消。Handler 返回后先停止监测,再复用连接和 Reader;普通短请求不会启动监测 goroutine。
如果客户端提前发送了下一条请求,监测器会保留管线数据并退出,避免预读不断占用内存。这种情况下,断开取消会延后到写失败或 Handler 返回;业务仍应为下游调用设置自己的超时。
服务端不是把连接交给一个全局串行 worker,而是每条连接独立处理请求;Keep-Alive 负责复用客户端连接,`Server` 内部的 `sync.Pool` 负责复用小型 Reader、响应对象和连接状态。Reader 默认只有 4 KiB,需要处理大请求头时按需增长。需要保护内存上限时,可以设置 `MaxConnections` 和 `MaxConcurrentRequests`;超出连接上限的连接会被关闭,超出请求并发上限的请求返回 503,不会无限排队。
这个设计借鉴了 Nginx 的资源边界,但没有照搬它的进程模型:Nginx 用少量 worker 进程和事件机制管理大量连接,并用 `worker_connections` 限制每个 worker 的同时连接数;Go runtime 自己已经用 epoll / kqueue 等网络轮询器唤醒阻塞的 goroutine,因此 native 采用“accept loop + 每连接轻量 goroutine + 请求并发闸门”的等价实现。这样业务 Handler 可以继续执行同步代码,慢请求不会阻塞其他连接;只有在真实压测证明 goroutine 调度或协议状态机成为瓶颈时,才需要继续下沉为手写事件循环。
使用自研服务端:
```go
application := omni.New()
server := native.New(nil)
if err := application.RunWith(ctx, server, ":8080"); err != nil {
log.Fatal(err)
}
```
`omni.New()` 默认仍使用 `transport/nethttp` 路由;native 服务端是显式启用的预览实现,等协议和连接边界稳定后再考虑切换默认启动器。
TLS 可以直接作为 `App.RunWith` 的启动配置:
```go
server := native.New(nil)
server.TLSConfig = &tls.Config{Certificates: certificates}
if err := application.RunWith(ctx, server, ":8443"); err != nil {
log.Fatal(err)
}
```
需要自己持有 listener 或配置证书文件时,分别使用 `ServeTLS` 或 `RunTLS`;TLS 握手仍受 native 的读超时、连接上限和优雅关闭控制,Handler 可通过 `Request.TLS` 读取标准 `tls.ConnectionState`。
## 可移植路由规则(v0.3.0) ## 可移植路由规则(v0.3.0)
| 项目 | 约定 | | 项目 | 约定 |
@@ -67,9 +103,9 @@ func TestContract(t *testing.T) {
```sh ```sh
go test -race ./transport/... ./examples/reference go test -race ./transport/... ./examples/reference
python3 scripts/verifyTransports.py --version v0.4.0 python3 scripts/verifyTransports.py --version v0.6.0-preview
``` ```
第一条验证标准库适配器复用完整 Handler / Service / Repository。第二条在源码目录外创建无 replace 的消费模块,从远程版本导入同一套验收包;自研适配器也应使用同一个公共测试包。 第一条验证标准库适配器复用完整 Handler / Service / Repository。第二条在源码目录外创建无 replace 的消费模块,从远程版本导入同一套验收包;自研适配器也应使用同一个公共测试包。
当前替换边界是路由与 HTTP 适配层,`App.Run` 仍使用标准库 `net/http.Server`。未来自研监听、连接管理或协议解析时,可让自研服务调用 `App.ServeHTTP`(提供标准 HTTP 请求/响应桥接),由自研启动层管理监听与关闭,并在退出时调用 `App.Close`;本阶段没有实现新的网络内核。 当前替换边界分成两层:`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 的前提下逐步替换解析器和响应写回。

View File

@@ -0,0 +1,125 @@
package transport_test
import (
"context"
"fmt"
"io"
"net"
"net/http"
"sync"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
// Both servers use the same application, client, request validation and fixed
// concurrency. B/op includes client AND server allocations in this process;
// ns/op is aggregate throughput cost, not individual request latency.
func BenchmarkTransportTCPApplicationRequest(b *testing.B) {
for _, adapter := range []string{"nethttp", "native"} {
for _, concurrency := range []int{1, 32, 128} {
b.Run(fmt.Sprintf("%s/concurrency%d", adapter, concurrency), func(b *testing.B) {
benchmarkTCP(b, adapter, concurrency)
})
}
}
}
func benchmarkTCP(b *testing.B, adapter string, concurrency int) {
application := omni.New()
application.GET("/health", func(ctx *omni.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatal(err)
}
done := make(chan error, 1)
var shutdown func(context.Context) error
if adapter == "native" {
server := native.New(application)
server.MaxConnections = 1024
server.MaxConcurrentRequests = 1024
shutdown = server.Shutdown
go func() { done <- server.Serve(context.Background(), listener) }()
} else {
server := &http.Server{
Handler: application, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: time.Minute, MaxHeaderBytes: 1 << 20,
}
shutdown = server.Shutdown
go func() {
err := server.Serve(listener)
if err == http.ErrServerClosed {
err = nil
}
done <- err
}()
}
client := &http.Client{Transport: &http.Transport{
MaxIdleConns: 1024,
MaxIdleConnsPerHost: 1024,
MaxConnsPerHost: concurrency,
IdleConnTimeout: time.Minute,
}, Timeout: 5 * time.Second}
b.Cleanup(func() {
client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdown(ctx); err != nil {
b.Error(err)
}
select {
case err := <-done:
if err != nil {
b.Error(err)
}
case <-ctx.Done():
b.Error("server did not stop")
}
})
endpoint := "http://" + listener.Addr().String() + "/health"
request := func() error {
response, err := client.Get(endpoint)
if err != nil {
return err
}
_, readErr := io.Copy(io.Discard, response.Body)
closeErr := response.Body.Close()
if response.StatusCode != http.StatusOK || response.Header.Get("X-Request-ID") == "" {
return fmt.Errorf("unexpected response: status=%d", response.StatusCode)
}
if readErr != nil {
return readErr
}
return closeErr
}
if err := request(); err != nil {
b.Fatal(err)
}
errors := make(chan error, concurrency)
var workers sync.WaitGroup
b.ReportAllocs()
b.ResetTimer()
for worker := 0; worker < concurrency; worker++ {
workers.Add(1)
go func(worker int) {
defer workers.Done()
for index := worker; index < b.N; index += concurrency {
if err := request(); err != nil {
errors <- err
return
}
}
}(worker)
}
workers.Wait()
b.StopTimer()
close(errors)
for err := range errors {
b.Error(err)
}
}

View File

@@ -0,0 +1,104 @@
package native
import (
"bufio"
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
)
type observedReadConn struct {
net.Conn
reading chan struct{}
}
func (c *observedReadConn) Read(data []byte) (int, error) {
select {
case c.reading <- struct{}{}:
default:
}
return c.Conn.Read(data)
}
func TestDisconnectWatchStopsBeforeReaderReuse(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
conn := &observedReadConn{Conn: server, reading: make(chan struct{}, 1)}
reader := bufio.NewReader(conn)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watch := newClientDisconnectWatch(reader, conn, cancel)
select {
case <-conn.reading:
case <-time.After(time.Second):
t.Fatal("disconnect watcher did not start reading")
}
watch.stop()
if ctx.Err() != nil {
t.Fatalf("interrupting the watcher canceled a healthy request: %v", ctx.Err())
}
if err := server.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
writeDone := make(chan error, 1)
go func() {
_, err := io.WriteString(client, "GET /next HTTP/1.1\r\n")
writeDone <- err
}()
line, err := reader.ReadString('\n')
if err != nil || line != "GET /next HTTP/1.1\r\n" {
t.Fatalf("reader retained watcher data or deadline: line=%q err=%v", line, err)
}
if err := <-writeDone; err != nil {
t.Fatal(err)
}
}
func TestDisconnectWatchPreservesPipelineAndWriteFailureCancels(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
pipeline := "GET /next HTTP/1.1\r\nHost: test\r\n\r\n"
reader := bufio.NewReader(strings.NewReader(pipeline))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watch := newClientDisconnectWatch(reader, server, cancel)
select {
case <-watch.done:
case <-time.After(time.Second):
t.Fatal("watcher should stop when pipelined data is available")
}
watch.stop()
if ctx.Err() != nil {
t.Fatal("pipeline data canceled the current request")
}
remaining, err := io.ReadAll(reader)
if err != nil || string(remaining) != pipeline {
t.Fatalf("pipeline bytes changed: data=%q err=%v", remaining, err)
}
// With pending pipeline data, EOF detection is deferred. A failed stream
// write must still cancel downstream work even when the handler ignores it.
client.Close()
writer := newResponseWriter(http.MethodGet)
writer.configure(&http.Request{ProtoMajor: 1, ProtoMinor: 1}, server, false, time.Second, cancel)
writer.Flush()
if !errors.Is(ctx.Err(), context.Canceled) {
t.Fatal("failed Flush did not cancel request context")
}
if _, err := writer.Write([]byte("more")); err == nil {
t.Fatal("failed Flush was not returned by subsequent Write")
}
if err := writer.writeTo(server, false, time.Second); err == nil {
t.Fatal("failed Flush was not returned on response completion")
}
writer.reset("")
if writer.cancel != nil || writer.writeErr != nil || writer.conn != nil {
t.Fatal("pooled response retained the previous request")
}
}

1105
transport/native/server.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
package native_test
import (
"context"
"io"
"net"
"net/http"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
func TestServerSlowHeadersCloseBeforeHandler(t *testing.T) {
var handlerCalls atomic.Int32
server := native.New(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
handlerCalls.Add(1)
}))
server.ReadHeaderTimeout = 40 * time.Millisecond
listener, 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(), listener) }()
defer stopServer(t, server, listener, done)
waitForServer(t, listener.Addr().String())
conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := io.WriteString(conn, "GET /health HTTP/1.1\r\nHost: local"); err != nil {
t.Fatal(err)
}
requirePeerClose(t, conn)
if handlerCalls.Load() != 0 {
t.Fatal("handler ran before slow headers completed")
}
}
func TestServerSlowChunkedBodyClosesBeforeHandler(t *testing.T) {
var handlerCalls atomic.Int32
server := native.New(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
handlerCalls.Add(1)
}))
server.ReadTimeout = 40 * time.Millisecond
listener, 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(), listener) }()
defer stopServer(t, server, listener, done)
waitForServer(t, listener.Addr().String())
conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
request := "POST /body HTTP/1.1\r\nHost: local\r\nTransfer-Encoding: chunked\r\n\r\n4\r\npa"
if _, err := io.WriteString(conn, request); err != nil {
t.Fatal(err)
}
requirePeerClose(t, conn)
if handlerCalls.Load() != 0 {
t.Fatal("handler ran before slow chunked body completed")
}
}

View File

@@ -0,0 +1,188 @@
package native_test
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/transport/native"
)
func startNativeTLS(t *testing.T, server *native.Server) (string, *tls.Config) {
t.Helper()
certificate := testCertificate(t)
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(leaf)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
})
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
t.Errorf("TLS shutdown: %v", err)
}
select {
case err := <-done:
if err != nil {
t.Errorf("TLS serve: %v", err)
}
case <-ctx.Done():
t.Error("TLS serve did not exit")
}
})
return listener.Addr().String(), &tls.Config{
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
}
}
func requirePeerClose(t *testing.T, conn net.Conn) {
t.Helper()
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := conn.Read(make([]byte, 1))
if err == nil {
t.Fatal("expected server to close the connection")
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Fatalf("client timed out; server did not close the connection: %v", err)
}
}
func TestNativeTLSSlowHandshakeTimesOut(t *testing.T) {
for _, prefix := range []string{"", "\x16\x03\x01\x00\x64\x01"} {
t.Run(fmt.Sprintf("partialBytes=%d", len(prefix)), func(t *testing.T) {
var calls atomic.Int32
server := native.New(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls.Add(1) }))
server.ReadHeaderTimeout = 50 * time.Millisecond
address, _ := startNativeTLS(t, server)
conn, err := net.DialTimeout("tcp", address, time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := io.WriteString(conn, prefix); err != nil {
t.Fatal(err)
}
requirePeerClose(t, conn)
if calls.Load() != 0 {
t.Fatal("unfinished TLS handshake reached the handler")
}
})
}
}
func TestNativeTLSConnectionLimitAndKeepAlive(t *testing.T) {
var calls atomic.Int32
server := native.New(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
_, _ = io.WriteString(w, "healthy")
}))
server.MaxConnections = 1
address, config := startNativeTLS(t, server)
dial := func() (*tls.Conn, error) {
return tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, config)
}
first, err := dial()
if err != nil {
t.Fatal(err)
}
defer first.Close()
first.SetDeadline(time.Now().Add(3 * time.Second))
reader := bufio.NewReader(first)
for index := 0; index < 3; index++ {
if _, err := io.WriteString(first, "GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n"); err != nil {
t.Fatal(err)
}
response, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil || string(body) != "healthy" || response.Close {
t.Fatalf("Keep-Alive response body=%q err=%v close=%v", body, err, response.Close)
}
}
// A completed TLS handshake proves the first slot is held before testing
// rejection. TCP accept can still succeed; the TLS handshake must fail.
rejected, err := dial()
if err == nil {
rejected.Close()
t.Fatal("TLS connection exceeded MaxConnections")
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Fatalf("over-limit handshake hung: %v", err)
}
first.Close()
deadline := time.Now().Add(2 * time.Second)
var next *tls.Conn
for time.Now().Before(deadline) {
next, err = dial()
if err == nil {
break
}
time.Sleep(time.Millisecond)
}
if err != nil {
t.Fatalf("TLS connection capacity not released: %v", err)
}
defer next.Close()
next.SetDeadline(time.Now().Add(time.Second))
if _, err := io.WriteString(next, "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); err != nil {
t.Fatal(err)
}
response, err := http.ReadResponse(bufio.NewReader(next), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil || string(body) != "healthy" || calls.Load() != 4 {
t.Fatalf("recovered response=%q calls=%d err=%v", body, calls.Load(), err)
}
}
func TestNativeTLSRequestMetadata(t *testing.T) {
server := native.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || !r.TLS.HandshakeComplete || r.TLS.NegotiatedProtocol != "http/1.1" {
http.Error(w, "missing TLS request metadata", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}))
address, config := startNativeTLS(t, server)
client := &http.Client{Transport: &http.Transport{TLSClientConfig: config}, Timeout: 2 * time.Second}
defer client.CloseIdleConnections()
response, err := client.Get("https://" + address + "/")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("TLS metadata unavailable to handler: status=%d", response.StatusCode)
}
}