Compare commits
63 Commits
v0.1.1
...
59bf04a461
| Author | SHA1 | Date | |
|---|---|---|---|
| 59bf04a461 | |||
| b54a303ccd | |||
| 84670f7be1 | |||
| fa2be5e016 | |||
| dfe8a1a9af | |||
| 830cb3a7ad | |||
| d94df4fc54 | |||
| 1a534c7976 | |||
| 2b93545507 | |||
| 15825f0ab8 | |||
| 1b9215114f | |||
| 73a05b8c3c | |||
| 64a9fde204 | |||
| ca0e1d6b92 | |||
| 98f17660c8 | |||
| c8660026ce | |||
| 695bdb7a00 | |||
| 7e9e8aa67c | |||
| de33eda846 | |||
| dd7d3fef0a | |||
| 46c5c9406c | |||
| 1487a96f67 | |||
| 1a20ab53ac | |||
| a072da4a31 | |||
| fcd6f48b53 | |||
| 83633f724e | |||
| a523e6ba3f | |||
| 4a8feaca36 | |||
| 95dc4568f6 | |||
| 2259606f72 | |||
| 35b7470be2 | |||
| 4f97aa0f3d | |||
| 3b4d0a3fad | |||
| a4d843b8f4 | |||
| fadd17a4d4 | |||
| ac46071dfe | |||
| 52d603c31e | |||
| 42021538b2 | |||
| ca707761fc | |||
| 30fdad44c2 | |||
| b7dc36fc99 | |||
| 8e474a24d9 | |||
| ef98d1dd8d | |||
| 5ab906098f | |||
| c5e415ca52 | |||
| 9c9c8ab59a | |||
| e6c82a01fe | |||
| 847621d218 | |||
| 4daf3b211c | |||
| 62b0f09546 | |||
| 694374fde8 | |||
| 22f1c9ebfb | |||
| b3cb9e8857 | |||
| efd3163902 | |||
| 241a568c83 | |||
| 22939fdb0b | |||
| 3d5f225fb2 | |||
| c36bb70f96 | |||
| 05b4088a25 | |||
| 3bbf4627a1 | |||
| 61c37e0dba | |||
| 5561795d80 | |||
| affd02d5f7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ bin/
|
||||
dist/
|
||||
*.log
|
||||
coverage.out
|
||||
__pycache__/
|
||||
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -1,5 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 新增 `TestNginxHTTP2Proxy` 与 `scripts/verifyHTTP2Proxy.py`:验证两段 TLS/h2、回源连接复用、证书校验拒绝、超时/客户端取消恢复,以及 GOAWAY 下的新旧实例切换和请求排空。Linux amd64、Nginx 1.30.5 普通与 race 模式各 3 轮通过;保留源码/镜像/日志校验信息和实际执行记录。
|
||||
- HTTP/2 代理验收的编译与测试阶段顺序运行,均限制 2 核、2 GiB、256 个进程/线程;集成测试禁用外部网络并使用只读根文件系统。总时限、SIGTERM、SIGHUP 清理实测通过;native 默认协议保持 HTTP/1.1。
|
||||
- 新增 `TestTLSMixedSoak` 与 `verifyHTTP2.py --scenario mixed`:8 条 TLS 连接、128 个 worker 持续混跑普通请求、慢上传和慢下载,校验 TLS/HTTP2、requestId、响应内容及传输摘要;分别统计三类请求与每条连接覆盖情况,并记录退出/GC 后的回收数据。
|
||||
- 完成 Linux amd64 30 分钟多连接慢客户端混合长测:53,191,687 次请求(含 106,971 次慢上传、5,588 次慢下载),0 失败与协议错误;采样 RSS 峰值约 116.6 MiB,连接/请求、goroutine 和 FD 回收,释放测试数据后存活堆约 450 KiB。保留完整日志、120 份采样及每条连接的分类计数。
|
||||
- 新增独立 `transport/http2` 实验 Runner:支持 h2c/TLS h2、连接上限、全局请求并发闸门、GOAWAY 排空、RST_STREAM 单流取消和生命周期统计;不改变 native HTTP/1.1 默认路径。
|
||||
- HTTP/2 实验 Runner 增加标准 1 MiB 头部上限、默认 8 MiB 请求体上限、128 路同连接多路复用、2 MiB 慢读取流控验收、协议错误、RST_STREAM 和 GOAWAY 帧计数;已声明超限请求在进入业务 handler 前返回 413。
|
||||
- 新增可配置时长的 HTTP/2 TLS soak:默认 3 秒,Linux 验收可通过 `GO_OMNI_HTTP2_SOAK_DURATION=30m` 覆盖,混合验证 128 路 stream、慢 handler、请求体、Keep-Alive 和退出回收。
|
||||
- HTTP/2 soak 每 15 秒输出请求/失败数、Go 堆、Linux RSS、goroutine、FD 和协议统计;校验响应协议、内容和独立请求 ID,不再忽略压测期限附近已发出请求的失败。
|
||||
- 补充 HTTP/2 慢响应流窗口为零时的隔离与恢复验收:同连接 204、另一连接带 body 响应、WINDOW_UPDATE 恢复发送及 RST_STREAM 释放请求槽位;补充 TLS 握手未完成时的连接上限拒绝、握手超时回收和正常请求恢复。
|
||||
- 补充连接级接收窗口耗尽测试:保持 stream 信用充足,消费完 65,535 字节连接窗口后确认大响应暂停,同连接 204 和另一条连接仍可完成,连接级 WINDOW_UPDATE 恢复后继续复用;补充零窗口阻塞写在关闭超时后解除阻塞、取消请求与连接回收的验收。
|
||||
- 新增 `scripts/verifyHTTP2.py`,固定在 Go 1.25 Linux 容器中运行 HTTP/2 TLS 长测;默认使用普通模式,`--race` 默认运行 60 秒。容器默认限制为 2 核、2 GiB 内存、256 个进程/线程,带总时限、中断清理和日志大小限制。
|
||||
- 完成 Linux amd64 单 TLS 连接、128 个 worker 的 30 分钟 HTTP/2 长测:58,102,476 次请求,0 失败与协议错误,采样 RSS 峰值约 22.6 MiB(包含客户端和服务端),退出后连接/请求/FD 回收;保留原始日志和 120 份采样。
|
||||
- native TLS 明确限制 ALPN 为 `http/1.1`:缺省自动宣告该协议,误配置 `h2` 等未实现协议时在启动阶段报错。
|
||||
- native `Server.Stats()` 新增连接和请求过载拒绝计数;新增可信代理配置,访问日志只在 socket 对端属于配置网段时解析 `X-Forwarded-For`。
|
||||
- 新增 transport-neutral `metrics` 包,支持请求数、错误数、in-flight、累计耗时和状态分布,并可直接注册 `/metrics` 接口。
|
||||
- native 新增 `Server.Stats()`,提供 accepted connections、active connections 和 active requests 生命周期指标。
|
||||
- native 请求体改为流式读取,Content-Length 与 chunked body 不再在进入 Handler 前整体复制到内存;保留大小限制、读超时、trailer 校验和 Keep-Alive 排空。
|
||||
- 统一标准库与 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、慢握手和连接上限回收验收。
|
||||
- 补充慢请求体超时、大请求体和同连接管线请求验收,确认流式 body 读取超时会取消请求并关闭连接,连续请求不会串线。
|
||||
- 补充 `MaxConnections` 满载拒绝与槽位释放验收,确认请求完成后连接容量可再次使用。
|
||||
- 补充 `MaxConcurrentRequests` 过载恢复验收,确认 503 拒绝后请求槽位释放并可继续处理新请求。
|
||||
- 增加高频建连/断连的状态池 churn 验收,覆盖连接对象回收与容量复用。
|
||||
- 新增 Linux Nginx 反向代理验收脚本,覆盖 native TLS、Keep-Alive、上游超时和优雅关闭。
|
||||
|
||||
## v0.6.0-preview
|
||||
|
||||
- native 网络层完成流式 Content-Length / chunked body、慢请求头、慢 body、慢 TLS 握手和 TLS 请求元数据验收;Handler 可在 body 未完全到达时开始读取,超时、畸形和超限 body 会被拒绝并关闭连接。
|
||||
- 将 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
|
||||
|
||||
- **破坏性变更**:移除 Gin 适配器及其全部依赖,`omni.New()` 默认使用只依赖标准库的 `transport/nethttp`。
|
||||
- 移除 Gin 专属的 `Config.Mode` / `WithMode` API;网络层运行模式由具体适配器或启动层自行配置。
|
||||
- 公共 `httpx.Router`、`transport/transporttest.Run` 和 `omni.NewWithRouter` 保留,自研网络层无需修改业务 Handler / Service 即可接入。
|
||||
- benchmark 改为标准库路由和完整 GoOmni 请求链,新增 `scripts/verifyPerformance.py` 作为 race、vet、build 和性能基线入口。
|
||||
- 远程业务试用、脚手架、适配器契约和 MySQL 验收全部迁移到 `v0.4.0`。
|
||||
|
||||
## v0.3.1
|
||||
|
||||
- 新增独立业务模块验收 `scripts/verifyBusiness.py`:从远程 `v0.2.0` 升级到 `v0.3.1`,验证无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
|
||||
- 脚手架与各项远程验收脚本默认跟随 `v0.3.1`,新项目会固定到最新补丁版本。
|
||||
|
||||
## v0.3.0
|
||||
|
||||
- 新增公共适配器验收套件 `transport/transporttest.Run` 和远程消费验收脚本 `scripts/verifyTransports.py`。Gin、nethttp 使用同一套路由与业务处理链测试,并通过完整用户 CRUD 与 race 验证。
|
||||
- 统一参数、分组、方法、URL 解码、路由优先级及 404/405 规则;Gin 405 的 Allow 由适配层计算,避免方法树回退状态带入无关方法。
|
||||
- 统一路由注册校验;非法路径、重复路由、参数名冲突和 catch-all 冲突在注册时 panic。
|
||||
- `App.Handle` / `RouterGroup.Handle` 支持在完整业务中间件链内显式注册 HEAD、OPTIONS 与扩展方法。
|
||||
- JSON 在提交响应前编码,编码失败可正确返回统一 500;通过 Context 已提交的响应不会因后续错误、panic 或重复响应追加内容,访问日志保留实际提交的状态。
|
||||
- 验证 requestId 与业务日志关联、认证授权短路、请求并发隔离、context 值与 deadline 保留,以及真实客户端取消传入 Service。
|
||||
- **升级行为调整**:Gin 不再自动重定向尾部斜杠;nethttp 不再忽略尾部斜杠,通配参数包含开头的 `/`,Any 收敛为九种标准方法。HEAD 不自动回退 GET,其他方法需显式注册。404/405 为统一纯文本响应,405 附排序后的 Allow。
|
||||
- `gin.NewWithEngine` 应用 GoOmni 路由策略;原生 Gin 扩展与 Raw 流式响应不属于可移植契约。详见 `transport/README.md`。
|
||||
- 新增业务试用验收 `scripts/verifyBusiness.py`:独立目录内固定远程版本,执行 `v0.2.0` → `v0.3.0` 升级,验证生成业务服务的配置、日志、requestId、CRUD、参数校验、错误映射和优雅退出。
|
||||
|
||||
## v0.2.0
|
||||
|
||||
- 新增 `go-omni new --database mysql`,生成数据库配置、MySQL Repository、ServiceContext、`schema.sql` 和数据库就绪探针;默认仍使用内存存储。
|
||||
- 两种存储模式的脚手架均提供完整 CRUD、分页、输入校验与统一响应。新生成项目的 `POST /users` 从直接返回用户对象改为标准响应封装,用户对象位于 `data`。
|
||||
- MySQL 启动时检查连接和表结构,失败时清理连接池;数据库停机后 readiness 返回 503,恢复后自动重新检查。
|
||||
- MySQL Repository 支持 `*sql.DB` 和 `*sql.Tx`,可在同一事务中复用业务查询。
|
||||
- 修复 `MaxIdleConns=0` 未生效的问题,数据库健康检查增加超时限制。
|
||||
- 新增隔离 MySQL 容器验收脚本,覆盖真实 CRUD、事务提交和错误/panic/取消回滚、持久化、资源清理及数据库停机恢复。
|
||||
|
||||
## v0.1.1
|
||||
|
||||
- 修复脚手架生成项目固定依赖不存在的 `v0.0.0`;远程安装的 CLI 默认使用自身版本,支持 `--framework-version` 指定完整版本号。
|
||||
|
||||
89
PERFORMANCE.md
Normal file
89
PERFORMANCE.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# GoOmni 性能基线
|
||||
|
||||
这轮测量显示 native 与标准库处于相同吞吐量级,在本机短时测试中,native 的空闲长连接堆内存更少。当前 native 仅实现 HTTP 的部分能力,尚不能据此判断生产容量或替代完整标准库服务端。
|
||||
|
||||
## 运行方式
|
||||
|
||||
```sh
|
||||
# race、vet、build,以及进程内和真实 TCP benchmark
|
||||
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
|
||||
|
||||
# Linux 上用 Nginx 反向代理接入 native TLS 服务,检查 Keep-Alive、超时和优雅关闭
|
||||
python3 scripts/verifyProxy.py
|
||||
```
|
||||
|
||||
可通过 `--concurrency 1,32,128` / `--idle 128,1024` 调整矩阵;传空字符串可跳过对应场景。工具只监听本机随机端口,每个场景启动独立服务进程,并在结束后关闭客户端、检查连接排空、关闭服务。响应状态、完整响应体、requestId 或传输错误均计入失败,不能把快速返回的错误页算成成功吞吐。
|
||||
|
||||
Linux 验收脚本使用 `golang:1.25` 容器,固定执行 `go test -race ./...`、`go vet ./...`、`go build ./...`,再按参数运行 perfcheck。perfcheck 在 Linux 读取 `/proc/self/fd`,报告连接建立期间的文件描述符峰值以及回收后的数量;同时记录服务端堆、goroutine、连接数和累计 accept 数。30 秒报告用于快速回归,30 分钟命令用于发布前持续压测。
|
||||
|
||||
## 2026-09-21 本机测量
|
||||
|
||||
环境: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 均回到启动附近。
|
||||
|
||||
目标 Linux amd64 服务器的 30 分钟 TLS 验收使用 1 个 CPU、1 GiB 内存的隔离 Docker 容器完成:net/http 约 11,865 req/s,native 约 13,098 req/s,两个流量场景均为 0 错误;native 的 P95/P99 为 44.040/52.429 ms。512 条空闲 TLS 连接时,native 服务端堆增量约 4.50 MiB(约 9,215 B/连接),文件描述符由 7 增至 519 后回到 7,goroutine 和连接均正常回收。原始报告保存在服务器的 `performance/linuxServerSoakTLS.json`。
|
||||
|
||||
`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 决定是否优化对象分配或事件状态机。
|
||||
106
README.md
106
README.md
@@ -1,17 +1,19 @@
|
||||
# GoOmni
|
||||
|
||||
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gin 作为网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
|
||||
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用只依赖标准库的网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
|
||||
|
||||
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.1.1
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.6.0-preview
|
||||
```
|
||||
|
||||
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
|
||||
|
||||
创建新业务项目:
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.1.1
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.6.0-preview
|
||||
go-omni new --module example.com/billing ./billing
|
||||
cd billing
|
||||
go mod tidy
|
||||
@@ -21,6 +23,10 @@ go run ./cmd/server
|
||||
远程安装的 CLI 默认引用自身版本,生成的项目不需要本地 `replace`。
|
||||
具体版本和本地开发选项见 [CLI 文档](cmd/go-omni/README.md)。
|
||||
|
||||
需要数据库持久化时,使用 `go-omni new --database mysql --module example.com/billing ./billing`。
|
||||
生成项目包含完整用户 CRUD、数据库 Repository、ServiceContext、建表脚本和就绪探针。
|
||||
先应用生成的 `schema.sql`,再配置 `GO_OMNI_DB_DSN`;启动时会检查连接与表结构,退出时释放连接池。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
@@ -32,11 +38,14 @@ go-omni/
|
||||
├── health/ # 存活和就绪检查
|
||||
├── middleware/ # 请求 ID 等基础中间件
|
||||
├── logx/ # 业务层结构化日志
|
||||
├── metrics/ # 请求指标与状态统计
|
||||
├── config/ # 配置管理
|
||||
├── validate/ # 参数校验
|
||||
├── auth/ # 认证和权限接口
|
||||
├── database/ # database/sql 生命周期和事务
|
||||
├── transport/gin/ # Gin 网络适配器,可替换
|
||||
├── transport/nethttp/ # 标准库网络适配器
|
||||
├── transport/native/ # 自研 HTTP/1.1 服务端预览
|
||||
├── transport/http2/ # opt-in HTTP/2 实验适配器
|
||||
├── examples/ # 示例项目
|
||||
└── cmd/ # go-omni 项目脚手架
|
||||
```
|
||||
@@ -76,17 +85,53 @@ func main() {
|
||||
return omni.NotFound("USER_NOT_FOUND", "user was not found")
|
||||
```
|
||||
|
||||
默认 Gin 适配器位于 `transport/gin`。业务层不需要导入 Gin;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用:
|
||||
默认网络适配器位于 `transport/nethttp`。业务层不需要导入具体网络库;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用:
|
||||
|
||||
```go
|
||||
application := omni.NewWithRouter(myRouter)
|
||||
```
|
||||
|
||||
仓库还提供了只依赖标准库的 `transport/nethttp` 参考实现,可以直接验证这条替换路径。
|
||||
仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。
|
||||
|
||||
`app.Engine()` 现在返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,避免业务 API 锁定 Gin 类型。
|
||||
自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.4.0 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。
|
||||
|
||||
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖 Gin:
|
||||
`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 上做网络层验证。
|
||||
|
||||
native TLS 当前只支持并宣告 ALPN `http/1.1`;配置 `h2` 会在启动时快速失败。需要 HTTP/2 时,可由 Nginx/Cloudflare 终止后回源 HTTP/1.1,或使用默认的 `transport/nethttp` 适配器。
|
||||
|
||||
`transport/http2` 是独立的 opt-in 实验适配器,使用 `x/net/http2` 处理帧和流控,同时保留 GoOmni 的连接上限、全局请求并发闸门和生命周期边界。它不会改变 native 默认路径;实验入口、限制和验收结果见 [HTTP/2 进入条件](transport/native/HTTP2.md)。
|
||||
|
||||
自研网络层还提供 `server.Stats()`,可读取 accepted、rejected、active connections 和 active requests,与上面的业务请求 metrics 组合后能区分连接堆积、过载拒绝和 Handler 慢请求。`rejectedConnections` 表示连接闸门拒绝数,`rejectedRequests` 表示并发请求闸门返回 503 的次数。
|
||||
|
||||
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库:
|
||||
|
||||
```go
|
||||
func (s *UserService) Create(ctx *httpx.Context) error {
|
||||
@@ -95,6 +140,16 @@ func (s *UserService) Create(ctx *httpx.Context) error {
|
||||
}
|
||||
```
|
||||
|
||||
需要基础运行指标时,可以注册 transport-neutral metrics middleware 和接口:
|
||||
|
||||
```go
|
||||
requestMetrics := omni.NewMetrics()
|
||||
app.Use(requestMetrics.Middleware())
|
||||
app.GET("/metrics", requestMetrics.Handler())
|
||||
```
|
||||
|
||||
接口返回请求总数、错误数、当前处理中请求、累计耗时和 HTTP 状态分布。它是进程内快照,生产环境可由业务侧定时采集到监控系统。
|
||||
|
||||
`WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。
|
||||
|
||||
应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`:
|
||||
@@ -114,7 +169,10 @@ application.OnShutdown(logger.Close)
|
||||
|
||||
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
|
||||
`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、
|
||||
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由
|
||||
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR`、`GO_OMNI_SHUTDOWN_TIMEOUT` 和
|
||||
`GO_OMNI_TRUSTED_PROXIES`。可信代理变量使用逗号分隔的 IP/CIDR,例如
|
||||
`GO_OMNI_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8`;只有来自这些地址的连接才会解析
|
||||
`X-Forwarded-For`,默认只记录 socket 对端地址。数据库连接仍由
|
||||
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
|
||||
|
||||
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。
|
||||
@@ -148,7 +206,11 @@ application.GET("/health/ready", checker.ReadinessHandler())
|
||||
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler:
|
||||
|
||||
```go
|
||||
services := servicecontext.New(logger, users)
|
||||
services, err := servicecontext.New(ctx, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer services.Close()
|
||||
userService := service.NewUsers(services)
|
||||
```
|
||||
|
||||
@@ -156,6 +218,28 @@ userService := service.NewUsers(services)
|
||||
|
||||
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
|
||||
|
||||
从远程版本生成并升级一个独立业务模块,可运行:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyBusiness.py --version v0.6.0-preview
|
||||
```
|
||||
|
||||
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.6.0-preview`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
|
||||
|
||||
标准库与 native 的并发、延迟和长连接内存对照见 [PERFORMANCE.md](PERFORMANCE.md)。`python3 scripts/verifyPerformance.py` 运行检查与 benchmark;增加 `--load --output /tmp/goOmniPerf.json` 可测量独立服务进程并保存报告。
|
||||
|
||||
HTTP/2 仍是显式 opt-in 实验适配器,不会改变 native 的 HTTP/1.1 默认路径。Linux TLS 长测使用 `python3 scripts/verifyHTTP2.py --duration 30m`,默认使用普通 Go 运行模式;`--race` 默认改为 60 秒,用于并发检查,其额外内存开销不能当成生产运行基线。
|
||||
|
||||
多连接混合场景使用 `python3 scripts/verifyHTTP2.py --scenario mixed --duration 30m`:8 条独立 TLS 连接,每条连接 14 个普通请求 worker、1 个慢上传 worker、1 个慢下载 worker,共 128 个 worker。上传为 256 KiB、每 4 KiB 等待 2 ms;下载为 8 MiB、每次最多读取 64 KiB 后等待 20 ms。日志分别记录三类成功数、传输字节数、每条连接的覆盖情况,以及停止并释放测试数据后两次 GC 的采样。
|
||||
|
||||
该脚本默认把测试容器限制为 2 核、2 GiB 内存(禁用 swap)、256 个进程/线程,整个命令最多运行 40 分钟,中断和超时会清理本次容器。这些限制可用 `--cpus`、`--memory-mb`、`--pids-limit`、`--wall-timeout` 调整;共享服务器应先用 `--duration 60s` 短测,再逐步延长。每 15 秒输出的堆、RSS 和 FD 统计包含同一进程内的客户端与服务端。
|
||||
|
||||
Linux amd64 已完成 30 分钟、单 TLS 连接、128 个 worker 的普通模式长测:58,102,476 次请求,0 失败、0 协议错误,采样 RSS 峰值约 22.6 MiB,连接/活动请求归零且 FD 回到基线。完整采样和原始日志见 [HTTP/2 长测报告](performance/http2SoakLinux.json) 与 [原始日志](performance/http2SoakLinux.txt),此前的 race 对照见 [短测记录](performance/http2SmokeLinux.json)。这项结果覆盖单连接复用。
|
||||
|
||||
多连接慢客户端场景也已完成 Linux amd64 的 30 分钟长测:8 条 TLS 连接、128 个 worker,53,079,128 次普通请求、106,971 次慢上传、5,588 次慢下载,共 53,191,687 次,0 失败与协议错误。采样 RSS 峰值约 116.6 MiB(包含客户端、服务端和下载缓冲);退出后连接/请求归零,goroutine 回到 2、FD 回到 6;释放测试数据并两次 GC 后存活堆约 450 KiB,RSS 不要求立即归还操作系统。完整 120 份采样见 [混合长测报告](performance/http2MixedSoakLinux.json) 和 [原始日志](performance/http2MixedSoakLinux.txt)。连接级窗口耗尽、WINDOW_UPDATE 恢复及零窗口写阻塞的关闭回收也已有独立测试;Nginx 的 HTTP/2 回源验收结果见下方,适配器保持实验状态。
|
||||
|
||||
`python3 scripts/verifyHTTP2Proxy.py --count 3` 验证真实 Nginx 双段 TLS/h2、回源连接复用、证书拒绝、超时/取消恢复与新旧实例的 GOAWAY 排空;增加 `--race` 可运行并发检查。Linux amd64、Nginx 1.30.5 的普通与 race 模式各 3 轮均已通过。编译和测试容器顺序运行,均限制为 2 核、2 GiB;测试容器禁用外部网络,全部请求在容器内完成。使用方法、报告及当前边界见 [HTTP/2 代理回源验收](transport/http2/PROXY.md)。
|
||||
|
||||
在框架根目录运行:
|
||||
|
||||
```sh
|
||||
@@ -164,7 +248,7 @@ go run ./examples/reference
|
||||
|
||||
默认地址为 `http://127.0.0.1:8080`。示例文档提供完整的 curl 操作流程。数据只保存在内存中,重启后清空。
|
||||
|
||||
框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入 Gin。
|
||||
框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入网络库。
|
||||
|
||||
## 当前边界
|
||||
|
||||
|
||||
55
ROADMAP.md
55
ROADMAP.md
@@ -8,21 +8,62 @@
|
||||
- 自动验收入口:`python3 scripts/verifyScaffold.py --version v0.1.1`。
|
||||
- 已通过全量 race 测试、vet、build,以及从远程提交安装 CLI 后的无 replace 端到端验收;覆盖生成服务和 `App.Run` 的两种退出信号。
|
||||
|
||||
## 2. 业务数据接入(待完成)
|
||||
## 2. 业务数据接入(已完成,v0.2.0)
|
||||
|
||||
- 数据库初始化、Repository 和 ServiceContext 组合成完整业务启动流程。
|
||||
- Readiness 注册数据库探针,启动失败与服务退出时清理连接池。
|
||||
- 验收:真实数据库 CRUD、事务回滚、数据库不可用时 readiness 返回 503。
|
||||
- `go-omni new --database mysql` 已生成完整数据接入流程,默认内存模式仍然可用。
|
||||
- 独立 MySQL 8.4 容器已验证 CRUD、唯一邮箱、分页、服务重启持久化、事务提交与错误/panic/取消回滚。
|
||||
- 已验证初始化失败、缺少表结构、正常退出时关闭连接池,以及数据库停止后的 readiness 503、恢复后的 200。
|
||||
- 自动验收入口:`python3 scripts/verifyDatabase.py --version v0.2.0`;结束后清理本次测试容器、匿名卷和临时项目。
|
||||
|
||||
## 3. 网络适配一致性(待完成)
|
||||
## 3. 网络适配一致性(已完成,v0.4.0)
|
||||
|
||||
- Gin 与标准库适配器运行同一套契约测试。
|
||||
- 标准库适配器运行公共契约测试,并为自研网络层保留相同的接入边界。
|
||||
- 覆盖路由参数、分组、中间件、错误响应和取消传播。
|
||||
- 验收:切换适配器后,业务 Handler 和 Service 无需修改,约定的 HTTP 行为一致。
|
||||
- 新增公共 `transport/transporttest.Run`,自研适配器可直接复用;覆盖静态 / 参数 / 通配路由、分组、405 Allow、鉴权短路、错误与 panic、响应提交、requestId、并发隔离、截止时间及真实客户端取消。
|
||||
- 标准库路由实现明确的路径、方法、尾部斜杠、通配参数、Any 和 404/405 约定;不再引入 Gin 依赖。
|
||||
- 完整用户 CRUD 已用标准库适配器验证;自研适配器可直接复用同一公共契约测试包。
|
||||
- 自动验收入口:`python3 scripts/verifyTransports.py --version v0.4.0`,从源码目录外消费远程版本与公共契约测试包。
|
||||
|
||||
## 4. 实际业务试用(待完成)
|
||||
## 4. 实际业务试用(已完成,v0.4.0)
|
||||
|
||||
- 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。
|
||||
- 根据真实接入问题收敛公共 API,再评估稳定版范围。
|
||||
- 在独立业务目录生成 billing service,先固定远程 `v0.2.0`,再升级到 `v0.4.0`,全程无本地 replace。
|
||||
- 验证环境配置、结构化访问日志、requestId 关联、参数校验、业务错误映射、CRUD、优雅退出和升级后的 race 测试。
|
||||
- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。
|
||||
- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.4.0`。
|
||||
|
||||
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段,数据库生产验收、自研网络内核与实际业务接入尚未完成。
|
||||
## 5. 自研网络层、稳定性与性能基线(v0.6.0-preview 验收完成)
|
||||
|
||||
- 已加入标准库路由和完整 GoOmni 请求链 benchmark,输出分配次数和耗时,作为未来自研网络层的对照基线。
|
||||
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端:自行管理 TCP listener、Keep-Alive、Content-Length / chunked 请求解析、响应写回、读写超时和优雅关闭;通过 `App.RunWith` 接入,业务 Handler / Service 保持不变。
|
||||
- 标准库与 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 请求体已改为流式读取,Handler 可边到边处理,服务端只在 Handler 返回后排空剩余 body,超时、畸形和超限 body 会取消请求并关闭连接。
|
||||
- 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 缓冲和反向代理联动。
|
||||
- 已验证 `MaxConcurrentRequests` 满载返回 503,活动请求结束后槽位释放,后续请求可以恢复处理。
|
||||
- native `Stats()` 已记录连接和请求过载拒绝数;访问日志已支持可信代理网段配置,默认拒绝伪造的 `X-Forwarded-For`。
|
||||
- native TLS 已明确只宣告 HTTP/1.1 ALPN;HTTP/2 暂由边缘代理终止或使用 `transport/nethttp`,已整理独立协议设计、流控和压测门槛,见 [native HTTP/2 进入条件](transport/native/HTTP2.md),完成后再接入 native。
|
||||
- 已加入独立 `transport/http2` 实验 Runner,验证 h2c/TLS h2、连接上限、全局请求并发拒绝和恢复,以及 GOAWAY 排空、RST_STREAM 单流取消和服务关闭超时;它使用 `x/net/http2` 作为临时帧/流控实现,已完成下述 Nginx HTTP/2 回源联调,尚未接入 native 默认路径。
|
||||
- HTTP/2 实验已补齐头部/请求体上限、128 路同连接多路复用、慢读取流控、协议错误、RST_STREAM 与 GOAWAY 统计,并提供 `scripts/verifyHTTP2.py --duration 30m` 的 Linux TLS 长测入口;长测默认不启用 race,短时并发检查使用 `--race`(默认 60 秒)。测试容器默认限制为 2 核、2 GiB 内存、256 个进程/线程,并设总时限和退出清理;受限长测和 Nginx 回源联调结果见下方。
|
||||
- 新 Linux amd64 环境在上述硬上限内完成 HTTP/2 普通模式 2 分钟与 race 模式 60 秒短测,分别为 3,885,273 和 377,788 次请求,均为 0 失败;连接/请求归零、FD 回到基线。SIGTERM、SIGHUP 和总时限退出清理已实测。报告见 [HTTP/2 短测记录](performance/http2SmokeLinux.json);此前无资源上限的 race 长测因 RSS 增长而停止,不作为通过依据。
|
||||
- 已完成受限的 HTTP/2 单连接 TLS 30 分钟长测:128 个 worker、58,102,476 次请求、0 失败及协议错误;包含客户端与服务端的采样 RSS 峰值约 22.6 MiB,连接/活动请求归零,FD 由 9 回到 6。120 份定时采样见 [HTTP/2 长测报告](performance/http2SoakLinux.json),测试提交和原始日志校验值保存在报告中。Nginx HTTP/2 回源的 GOAWAY/排空结果见下方。
|
||||
- 已完成 8 条 TLS 连接、128 个 worker 的 30 分钟混合长测:53,079,128 次普通请求、106,971 次慢上传、5,588 次慢下载,0 失败及协议错误。采样 RSS 峰值约 116.6 MiB(包含客户端和服务端),前段与最后 5 分钟 RSS 中位数分别约 111.3/110.4 MiB;退出后活动连接/请求归零,goroutine 2、FD 6 均回到基线,两次 GC 后存活堆约 450 KiB。原始日志、120 份采样、测试提交和校验值见 [混合长测报告](performance/http2MixedSoakLinux.json)。
|
||||
- 已补连接级窗口耗尽时的暂停、同连接 204/跨连接隔离、WINDOW_UPDATE 恢复和连接复用;已补零窗口写阻塞在关闭超时后的解除阻塞、请求取消与回收。Linux amd64 的 HTTP/2 全包 race、vet、build 已通过,见 [边界回归报告](performance/http2FlowControlLinux.json)。
|
||||
- 新增 HTTP/2 零 stream 窗口时的慢响应隔离、WINDOW_UPDATE 恢复、RST_STREAM 取消与请求槽位回收测试;补充 TLS 半开握手占满连接上限时的及时拒绝、超时回收及正常请求恢复测试。
|
||||
- 已完成 Nginx 1.30.5 → HTTP/2 的 TLS 回源联调:普通与 race 模式各 3 轮,共 18 个子场景通过;覆盖两段证书校验、32 次 POST 复用一条回源连接、不可信 CA/主机名拒绝、504 超时、客户端取消、GOAWAY 后旧 POST 完成及 20 个新 POST 转入新实例,无重复业务执行。见 [验收说明与报告](transport/http2/PROXY.md)。
|
||||
- `scripts/verifyHTTP2Proxy.py` 顺序运行受限编译容器和禁用外部网络的测试容器;总时限、SIGTERM、SIGHUP 清理已在 Linux 实测,原有 7 个业务容器仍运行。HTTP/2 下一步是统一启动配置、native 多协议接入回归与业务试用;当前结果不覆盖 Nginx master/worker 重载或 Cloudflare 实网部署。
|
||||
- 已用 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 回收正常。
|
||||
- 目标 Linux amd64 服务器已完成 30 分钟、128 并发、512 空闲连接的 TLS 验收:net/http 约 11,865 req/s、native 约 13,098 req/s,均为 0 错误;native P95/P99 为 44.040/52.429 ms,空闲连接堆增量约 4.50 MiB,FD 由 7 增至 519 后回到 7。
|
||||
- 新增 `scripts/verifyProxy.py`,在 Linux Docker 中用 Nginx 反向代理 native TLS 服务,检查真实 TLS、Keep-Alive、上游超时和 native 优雅关闭。
|
||||
- 自动验收入口:`python3 scripts/verifyPerformance.py`;Linux 固定验收入口:`python3 scripts/verifyLinux.py --load --tls --duration 30m`;代理联调入口:`python3 scripts/verifyProxy.py`。
|
||||
|
||||
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。
|
||||
|
||||
60
app/app.go
60
app/app.go
@@ -15,24 +15,32 @@ import (
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
ginrouter "git.yuntuo.hk/k2safe/go-omni/transport/gin"
|
||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
)
|
||||
|
||||
// App is the top-level GoOmni application. Its public behavior depends on
|
||||
// httpx.Router, leaving the network implementation replaceable.
|
||||
type App struct {
|
||||
router httpx.Router
|
||||
config config.Config
|
||||
middlewares []httpx.Middleware
|
||||
closers []func() error
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
router httpx.Router
|
||||
config config.Config
|
||||
middlewares []httpx.Middleware
|
||||
closers []func() error
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
clientIPResolver *httpx.ClientIPResolver
|
||||
}
|
||||
|
||||
// New creates an application using the default Gin adapter.
|
||||
// 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.
|
||||
func New(options ...config.Option) *App {
|
||||
settings := configure(options...)
|
||||
return newApp(ginrouter.New(settings.Mode), settings)
|
||||
return newApp(netrouter.New(), settings)
|
||||
}
|
||||
|
||||
// NewWithRouter creates an application with a custom network layer. A custom
|
||||
@@ -56,19 +64,24 @@ func configure(options ...config.Option) config.Config {
|
||||
}
|
||||
|
||||
func newApp(router httpx.Router, settings config.Config) *App {
|
||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...)
|
||||
if err != nil {
|
||||
panic("go-omni: " + err.Error())
|
||||
}
|
||||
middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)}
|
||||
if settings.EnableLogger {
|
||||
middlewares = append(middlewares, middleware.AccessLog())
|
||||
}
|
||||
middlewares = append(middlewares, middleware.Recovery())
|
||||
return &App{router: router, config: settings, middlewares: middlewares}
|
||||
return &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver}
|
||||
}
|
||||
|
||||
// Router returns the transport contract used by this application.
|
||||
func (a *App) Router() httpx.Router { return a.router }
|
||||
|
||||
// Engine is kept as a compatibility name for the old API. It returns a
|
||||
// standard http.Handler, so callers no longer need a Gin type.
|
||||
// standard http.Handler, so callers do not depend on a router type.
|
||||
func (a *App) Engine() http.Handler { return a.router }
|
||||
|
||||
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
||||
@@ -77,7 +90,11 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
}
|
||||
|
||||
// Config returns the effective application configuration.
|
||||
func (a *App) Config() config.Config { return a.config }
|
||||
func (a *App) Config() config.Config {
|
||||
settings := a.config
|
||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
return settings
|
||||
}
|
||||
|
||||
// Use adds middleware to routes registered after this call.
|
||||
func (a *App) Use(middleware ...httpx.Middleware) *App {
|
||||
@@ -150,6 +167,25 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
||||
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 {
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- server.Serve(listener) }()
|
||||
|
||||
@@ -21,6 +21,9 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.
|
||||
}
|
||||
}
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if len(a.config.TrustedProxies) > 0 {
|
||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
|
||||
}
|
||||
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
|
||||
if err := wrapped(requestContext); err != nil {
|
||||
a.writeError(requestContext, err)
|
||||
@@ -28,6 +31,12 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.
|
||||
})
|
||||
}
|
||||
|
||||
// Handle registers an explicit method, including HEAD, OPTIONS and extension
|
||||
// methods, through the same business middleware and error handling as GET.
|
||||
func (a *App) Handle(method, path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
a.handle(a.router, method, path, handler, middleware)
|
||||
}
|
||||
|
||||
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
a.handle(a.router, http.MethodGet, path, handler, middleware)
|
||||
}
|
||||
@@ -80,6 +89,11 @@ func (g *RouterGroup) route(method, path string, handler httpx.Handler, middlewa
|
||||
g.app.handle(g.group, method, path, handler, combined)
|
||||
}
|
||||
|
||||
// Handle registers an explicit method within this group's middleware chain.
|
||||
func (g *RouterGroup) Handle(method, path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
g.route(method, path, handler, middleware)
|
||||
}
|
||||
|
||||
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
g.route(http.MethodGet, path, handler, middleware)
|
||||
}
|
||||
|
||||
43
app_test.go
43
app_test.go
@@ -8,12 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAppReturnsStandardResponse(t *testing.T) {
|
||||
app := New(WithMode(gin.TestMode))
|
||||
app := New()
|
||||
app.GET("/health", func(ctx *Context) error {
|
||||
return ctx.OK(map[string]string{"status": "ok"})
|
||||
})
|
||||
@@ -41,7 +39,7 @@ func TestAppReturnsStandardResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppReturnsAPIError(t *testing.T) {
|
||||
app := New(WithMode(gin.TestMode))
|
||||
app := New()
|
||||
app.GET("/users/:id", func(ctx *Context) error {
|
||||
return NotFound("USER_NOT_FOUND", "user was not found")
|
||||
})
|
||||
@@ -63,7 +61,7 @@ func TestAppReturnsAPIError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppRecoversFromPanic(t *testing.T) {
|
||||
app := New(WithMode(gin.TestMode))
|
||||
app := New()
|
||||
app.GET("/panic", func(ctx *Context) error {
|
||||
panic("boom")
|
||||
})
|
||||
@@ -85,7 +83,7 @@ func TestAppRecoversFromPanic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppRunContextShutsDownGracefully(t *testing.T) {
|
||||
app := New(WithMode(gin.TestMode), WithShutdownTimeout(time.Second))
|
||||
app := New(WithShutdownTimeout(time.Second))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- app.RunContext(ctx, "127.0.0.1:0") }()
|
||||
@@ -102,7 +100,7 @@ func TestAppRunContextShutsDownGracefully(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppClosesResourcesInReverseOrder(t *testing.T) {
|
||||
app := New(WithMode(gin.TestMode))
|
||||
app := New()
|
||||
var closed []string
|
||||
app.OnShutdown(func() error {
|
||||
closed = append(closed, "database")
|
||||
@@ -130,3 +128,34 @@ func TestAppUsesConfiguredAddressWhenNoOverrideIsProvided(t *testing.T) {
|
||||
t.Fatalf("expected configured address %q, got %q", settings.Address, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigCopiesTrustedProxies(t *testing.T) {
|
||||
proxies := []string{"127.0.0.1"}
|
||||
app := New(WithTrustedProxies(proxies...))
|
||||
proxies[0] = "10.0.0.0/8"
|
||||
settings := app.Config()
|
||||
if len(settings.TrustedProxies) != 1 || settings.TrustedProxies[0] != "127.0.0.1" {
|
||||
t.Fatalf("trusted proxy config was not copied: %+v", settings.TrustedProxies)
|
||||
}
|
||||
settings.TrustedProxies[0] = "192.0.2.0/24"
|
||||
if app.Config().TrustedProxies[0] != "127.0.0.1" {
|
||||
t.Fatal("App.Config should return a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppTrustedProxyClientIPReachesHandlers(t *testing.T) {
|
||||
app := New(WithTrustedProxies("127.0.0.1"))
|
||||
app.GET("/client-ip", func(ctx *Context) error { return ctx.OK(ctx.ClientIP()) })
|
||||
request := httptest.NewRequest(http.MethodGet, "/client-ip", nil)
|
||||
request.RemoteAddr = "127.0.0.1:443"
|
||||
request.Header.Set("X-Forwarded-For", "198.51.100.7")
|
||||
recorder := httptest.NewRecorder()
|
||||
app.Engine().ServeHTTP(recorder, request)
|
||||
var response Response
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Data != "198.51.100.7" {
|
||||
t.Fatalf("expected trusted proxy client IP, got %#v", response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,10 @@ import (
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRequireAuthAndRole(t *testing.T) {
|
||||
app := omni.New(omni.WithMode(gin.TestMode))
|
||||
app := omni.New()
|
||||
authenticator := auth.StaticAuthenticator{"good-token": {UserID: "user-1", Roles: []string{"user"}}}
|
||||
private := app.Group("/private", auth.RequireAuth(authenticator))
|
||||
private.GET("/me", func(ctx *omni.Context) error {
|
||||
@@ -62,7 +61,7 @@ func TestRequireAuthAndRole(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOptionalAuth(t *testing.T) {
|
||||
app := omni.New(omni.WithMode(gin.TestMode))
|
||||
app := omni.New()
|
||||
app.GET("/public", func(ctx *omni.Context) error {
|
||||
principal, authenticated := auth.FromContext(ctx)
|
||||
return ctx.OK(map[string]any{"authenticated": authenticated, "userId": principal.UserID})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.1.1
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.6.0-preview
|
||||
go-omni new --module example.com/billing ./billing
|
||||
cd billing
|
||||
go mod tidy
|
||||
@@ -16,7 +16,7 @@ go run ./cmd/server
|
||||
需要选择其他已发布版本时,使用完整版本号:
|
||||
|
||||
```sh
|
||||
go-omni new --framework-version v0.1.1 --module example.com/billing ./billing
|
||||
go-omni new --framework-version v0.6.0-preview --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
|
||||
@@ -32,13 +32,24 @@ go-omni new --framework-version v0.1.1 --module example.com/billing ./billing
|
||||
go-omni new --framework-dir /path/to/go-omni --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
生成服务默认包含 `/health/live`、`/health/ready` 和示例 `POST /users`。
|
||||
默认生成内存存储版本;选择 MySQL 时:
|
||||
|
||||
```sh
|
||||
go-omni new --database mysql --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库集成测试。
|
||||
先应用建表脚本,再设置 `GO_OMNI_DB_DSN` 启动。ServiceContext 负责连接池初始化、表结构检查、
|
||||
就绪探针注册和关闭。两种模式都提供用户 CRUD、分页、输入校验和统一错误响应。
|
||||
|
||||
生成服务默认包含 `/health/live`、`/health/ready` 和示例 `/users` CRUD。
|
||||
退出信号通过 `signal.NotifyContext` 传入 `RunContext`,Logger 的关闭函数注册到应用生命周期。
|
||||
|
||||
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyScaffold.py --version v0.1.1
|
||||
python3 scripts/verifyScaffold.py --version v0.6.0-preview
|
||||
python3 scripts/verifyDatabase.py --version v0.6.0-preview
|
||||
```
|
||||
|
||||
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
||||
@@ -50,4 +61,7 @@ python3 scripts/verifyScaffold.py --version v0.1.1
|
||||
```sh
|
||||
go build -o /tmp/go-omni ./cmd/go-omni
|
||||
python3 scripts/verifyScaffold.py --cli /tmp/go-omni --framework-dir .
|
||||
python3 scripts/verifyDatabase.py --cli /tmp/go-omni --framework-dir .
|
||||
```
|
||||
|
||||
数据库验收需要本地 Docker,使用独立 MySQL 8.4 容器;不会连接已有业务数据库,结束后清理测试容器与数据卷。
|
||||
|
||||
@@ -39,6 +39,17 @@ func runNew(args []string) error {
|
||||
switch {
|
||||
case arg == "--force":
|
||||
options.Force = true
|
||||
case arg == "--database":
|
||||
if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" {
|
||||
return errors.New("--database requires memory or mysql")
|
||||
}
|
||||
index++
|
||||
options.Database = args[index]
|
||||
case strings.HasPrefix(arg, "--database="):
|
||||
options.Database = strings.TrimPrefix(arg, "--database=")
|
||||
if strings.TrimSpace(options.Database) == "" {
|
||||
return errors.New("--database requires memory or mysql")
|
||||
}
|
||||
case arg == "--module":
|
||||
if index+1 >= len(args) {
|
||||
return errors.New("--module requires a value")
|
||||
@@ -95,4 +106,5 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
|
||||
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
|
||||
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
|
||||
fmt.Fprintln(os.Stderr, " --database <memory|mysql> Select generated storage (default memory)")
|
||||
}
|
||||
|
||||
@@ -31,3 +31,21 @@ func TestNewRejectsEmptyFrameworkVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMySQLDatabaseOptions(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--database", "mysql"}, {"--database=mysql"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(project, "schema.sql")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"--database"}, {"--database="}, {"--database", " "}} {
|
||||
if err := runNew(args); err == nil {
|
||||
t.Fatalf("accepted missing database type: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -22,6 +23,7 @@ type Options struct {
|
||||
Module string
|
||||
FrameworkDir string
|
||||
FrameworkVersion string
|
||||
Database string
|
||||
Force bool
|
||||
}
|
||||
|
||||
@@ -30,6 +32,8 @@ var files = map[string]string{
|
||||
"README.md": "templates/README.md.tmpl",
|
||||
"cmd/server/main.go": "templates/cmd-server-main.go.tmpl",
|
||||
"internal/model/user.go": "templates/internal-model-user.go.tmpl",
|
||||
"internal/model/errors.go": "templates/internal-model-errors.go.tmpl",
|
||||
"internal/repository/users.go": "templates/internal-repository-users.go.tmpl",
|
||||
"internal/repository/memory.go": "templates/internal-repository-memory.go.tmpl",
|
||||
"internal/servicecontext/context.go": "templates/internal-servicecontext-context.go.tmpl",
|
||||
"internal/service/user.go": "templates/internal-service-user.go.tmpl",
|
||||
@@ -44,6 +48,13 @@ func Generate(options Options) error {
|
||||
if strings.TrimSpace(options.Module) == "" {
|
||||
return errors.New("module path is required")
|
||||
}
|
||||
database := strings.ToLower(strings.TrimSpace(options.Database))
|
||||
if database == "" {
|
||||
database = "memory"
|
||||
}
|
||||
if database != "memory" && database != "mysql" {
|
||||
return fmt.Errorf("unsupported database %q: choose memory or mysql", database)
|
||||
}
|
||||
version := strings.TrimSpace(options.FrameworkVersion)
|
||||
if version == "" {
|
||||
version = currentFrameworkVersion()
|
||||
@@ -72,17 +83,34 @@ func Generate(options Options) error {
|
||||
Module: options.Module,
|
||||
FrameworkVersion: version,
|
||||
FrameworkReplace: replace,
|
||||
Database: database,
|
||||
}
|
||||
paths := make([]string, 0, len(files))
|
||||
for path := range files {
|
||||
projectFiles := make(map[string]string, len(files)+3)
|
||||
for path, source := range files {
|
||||
projectFiles[path] = source
|
||||
}
|
||||
if database == "mysql" {
|
||||
projectFiles["internal/repository/mysql.go"] = "templates/internal-repository-mysql.go.tmpl"
|
||||
projectFiles["schema.sql"] = "templates/schema.sql.tmpl"
|
||||
projectFiles["internal/servicecontext/databaseIntegration_test.go"] = "templates/databaseIntegration_test.go.tmpl"
|
||||
}
|
||||
paths := make([]string, 0, len(projectFiles))
|
||||
for path := range projectFiles {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
content, err := render(files[path], data)
|
||||
content, err := render(projectFiles[path], data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render %s: %w", path, err)
|
||||
}
|
||||
if strings.HasSuffix(path, ".go") {
|
||||
formatted, err := format.Source([]byte(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("format %s: %w", path, err)
|
||||
}
|
||||
content = string(formatted)
|
||||
}
|
||||
target := filepath.Join(directory, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
|
||||
@@ -105,6 +133,7 @@ type templateData struct {
|
||||
Module string
|
||||
FrameworkVersion string
|
||||
FrameworkReplace string
|
||||
Database string
|
||||
}
|
||||
|
||||
func render(name string, data templateData) (string, error) {
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestGenerateCreatesServiceLayout(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read generated server: %v", err)
|
||||
}
|
||||
for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(logger, users)", "health.New()", "application.RunContext(ctx, settings.Address)"} {
|
||||
for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(ctx, logger)", "services.Health.ReadinessHandler()", "application.RunContext(ctx, settings.Address)"} {
|
||||
if !strings.Contains(string(mainContent), expected) {
|
||||
t.Fatalf("generated server missing %q: %s", expected, mainContent)
|
||||
}
|
||||
@@ -56,6 +56,35 @@ func TestGenerateRejectsNonEmptyDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMySQLLayout(t *testing.T) {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
if err := Generate(Options{Dir: project, Module: "example.com/billing", Database: "mysql", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"schema.sql", "internal/repository/mysql.go", "internal/repository/users.go", "internal/model/errors.go", "internal/servicecontext/databaseIntegration_test.go"} {
|
||||
if _, err := os.Stat(filepath.Join(project, path)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(project, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "github.com/go-sql-driver/mysql v1.9.3") {
|
||||
t.Fatal("mysql driver version is not pinned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsUnknownDatabaseBeforeWriting(t *testing.T) {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
if err := Generate(Options{Dir: project, Module: "example.com/billing", Database: "unknown"}); err == nil {
|
||||
t.Fatal("accepted unknown backend")
|
||||
}
|
||||
if _, err := os.Stat(project); !os.IsNotExist(err) {
|
||||
t.Fatalf("invalid backend created files: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
|
||||
root := frameworkRoot(t)
|
||||
project := filepath.Join(t.TempDir(), "generated")
|
||||
|
||||
@@ -4,6 +4,26 @@ This service was generated with `go-omni new`.
|
||||
|
||||
Framework dependency: `git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}`.
|
||||
|
||||
Storage: `{{.Database}}`.
|
||||
|
||||
{{if eq .Database "mysql"}}
|
||||
先创建业务数据库,再执行项目的建表脚本(替换示例数据库名、账号和地址):
|
||||
|
||||
```sh
|
||||
mysql -h 127.0.0.1 -u app -p app < schema.sql
|
||||
```
|
||||
|
||||
配置数据库连接后启动;初始化失败或缺少表结构时,服务会退出并关闭已创建的连接池:
|
||||
|
||||
```sh
|
||||
export GO_OMNI_DB_DSN='app:password@tcp(127.0.0.1:3306)/app?parseTime=true'
|
||||
```
|
||||
|
||||
应用只检查表结构,不会自动建库、建表或迁移。MySQL 驱动由本项目注册,核心框架仍依赖标准库 `database/sql`。
|
||||
{{else}}
|
||||
默认使用进程内存存储,服务重启后数据清空。需要 MySQL 时,生成新项目时选择 `--database mysql`。
|
||||
{{end}}
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
@@ -26,10 +46,59 @@ go build -o server ./cmd/server
|
||||
./server
|
||||
```
|
||||
|
||||
`GO_OMNI_SHUTDOWN_TIMEOUT` 控制等待请求完成的时间,默认 `10s`。健康探针目前没有
|
||||
数据库依赖检查;接入数据库后需要向 Checker 注册探针。
|
||||
`GO_OMNI_SHUTDOWN_TIMEOUT` 控制等待请求完成的时间,默认 `10s`。
|
||||
{{if eq .Database "mysql"}}
|
||||
`/health/ready` 已注册数据库探针:连接失败返回 `503`,数据库恢复后重新返回 `200`。
|
||||
`/health/live` 始终只检查服务存活。`GO_OMNI_DB_PING_TIMEOUT` 限制初始化检查和数据库探针的等待时间。
|
||||
退出时先排空 HTTP 请求,再关闭数据库连接池,最后关闭日志。
|
||||
|
||||
The generated layout keeps HTTP handlers, business services, repositories,
|
||||
models and a project-owned ServiceContext separate. Replace the memory
|
||||
repository with a database implementation when the service is ready for
|
||||
persistence, then add it to `internal/servicecontext`.
|
||||
数据库连接池参数:
|
||||
|
||||
```text
|
||||
GO_OMNI_DB_MAX_OPEN_CONNS=10
|
||||
GO_OMNI_DB_MAX_IDLE_CONNS=5
|
||||
GO_OMNI_DB_CONN_MAX_LIFETIME=30m
|
||||
GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m
|
||||
GO_OMNI_DB_PING_TIMEOUT=5s
|
||||
```
|
||||
|
||||
`MAX_IDLE_CONNS=0` 禁用空闲连接,`MAX_OPEN_CONNS=0` 表示不限制连接数。
|
||||
项目会启用 MySQL `parseTime` 并按 UTC 读取 DATETIME 字段,JSON 字段保持 lowerCamelCase。
|
||||
|
||||
事务内复用 Repository(在业务代码中导入 `database/sql` 和项目的 `internal/repository`):
|
||||
|
||||
```go
|
||||
err := services.DB.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
users := repository.NewMySQLUsers(tx)
|
||||
_, err := users.Create(ctx, input)
|
||||
return err
|
||||
})
|
||||
```
|
||||
|
||||
业务函数返回错误、发生 panic 或 context 取消时会回滚。事务内必须使用基于 `tx` 创建的 Repository。
|
||||
|
||||
可选集成测试(只针对已应用 schema.sql 的独立测试数据库):
|
||||
|
||||
```sh
|
||||
GO_OMNI_TEST_MYSQL_DSN='app:password@tcp(127.0.0.1:3306)/test?parseTime=true' go test -race ./...
|
||||
```
|
||||
|
||||
未设置 `GO_OMNI_TEST_MYSQL_DSN` 时跳过数据库集成测试。框架仓库中的 `scripts/verifyDatabase.py`
|
||||
可以自动创建、验收和清理独立的 MySQL 容器。
|
||||
{{else}}
|
||||
内存模式没有外部依赖探针;后续添加依赖时,在 `internal/servicecontext` 注册检查与资源清理。
|
||||
{{end}}
|
||||
|
||||
示例接口(无认证,接入实际业务时按项目需求添加认证中间件):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/users` | 创建,JSON 包含 name、email |
|
||||
| GET | `/users/:id` | 查询 |
|
||||
| GET | `/users?page=1&pageSize=20` | 分页 |
|
||||
| PUT | `/users/:id` | 更新 name、email |
|
||||
| DELETE | `/users/:id` | 删除,返回 204 |
|
||||
|
||||
接口使用 GoOmni 统一响应,包含 code、message、data 和 requestId。邮箱按小写存储,重复返回 409;
|
||||
无效输入返回 400,找不到记录返回 404。项目按 Handler / Service / Repository / Model 分层,
|
||||
基础依赖集中放在 `internal/servicecontext`。
|
||||
|
||||
@@ -10,10 +10,8 @@ import (
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/handler"
|
||||
"{{.Module}}/internal/repository"
|
||||
"{{.Module}}/internal/service"
|
||||
"{{.Module}}/internal/servicecontext"
|
||||
)
|
||||
@@ -43,19 +41,17 @@ func run() error {
|
||||
// Access logs and business logs share the same configured sink.
|
||||
logx.SetDefault(logger)
|
||||
|
||||
users := repository.NewMemoryUsers()
|
||||
services := servicecontext.New(logger, users)
|
||||
services, err := servicecontext.New(ctx, logger)
|
||||
if err != nil { return err }
|
||||
defer services.Close()
|
||||
userService := service.NewUsers(services)
|
||||
userHandler := handler.NewUsers(userService)
|
||||
checker, err := health.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
application := omni.New(omni.WithConfig(settings))
|
||||
application.OnShutdown(logger.Close)
|
||||
application.GET("/health/live", checker.LivenessHandler())
|
||||
application.GET("/health/ready", checker.ReadinessHandler())
|
||||
application.OnShutdown(services.Close)
|
||||
application.GET("/health/live", services.Health.LivenessHandler())
|
||||
application.GET("/health/ready", services.Health.ReadinessHandler())
|
||||
userHandler.RegisterRoutes(application)
|
||||
return application.RunContext(ctx, settings.Address)
|
||||
}
|
||||
|
||||
106
cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl
Normal file
106
cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl
Normal file
@@ -0,0 +1,106 @@
|
||||
package servicecontext_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/model"
|
||||
"{{.Module}}/internal/repository"
|
||||
"{{.Module}}/internal/servicecontext"
|
||||
)
|
||||
|
||||
// These tests require a dedicated database with schema.sql applied. Ordinary
|
||||
// go test runs skip them; scripts/verifyDatabase.py provisions an isolated DB.
|
||||
func databaseContext(t *testing.T) *servicecontext.Context {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("GO_OMNI_TEST_MYSQL_DSN")
|
||||
if dsn == "" { t.Skip("set GO_OMNI_TEST_MYSQL_DSN to a dedicated test database") }
|
||||
for _, key := range []string{"MAX_OPEN_CONNS", "MAX_IDLE_CONNS", "CONN_MAX_LIFETIME", "CONN_MAX_IDLE_TIME", "PING_TIMEOUT"} {
|
||||
t.Setenv("GO_OMNI_DB_"+key, "")
|
||||
}
|
||||
t.Setenv("GO_OMNI_DB_DSN", dsn)
|
||||
t.Setenv("GO_OMNI_DB_DRIVER", "mysql")
|
||||
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
||||
if err != nil { t.Fatal(err) }
|
||||
t.Cleanup(func() { _ = logger.Close() })
|
||||
services, err := servicecontext.New(context.Background(), logger)
|
||||
if err != nil { t.Fatal(err) }
|
||||
t.Cleanup(func() { _ = services.Close() })
|
||||
return services
|
||||
}
|
||||
|
||||
func TestDatabaseTransactions(t *testing.T) {
|
||||
services := databaseContext(t)
|
||||
ctx := context.Background()
|
||||
for _, mode := range []string{"commit", "error", "panic", "cancel"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
email := fmt.Sprintf("transaction-%d-%s@example.com", time.Now().UnixNano(), mode)
|
||||
t.Cleanup(func() { _, _ = services.DB.ExecContext(ctx, "DELETE FROM users WHERE email = ?", email) })
|
||||
txCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
abort := errors.New("abort business operation")
|
||||
var err error
|
||||
var recovered any
|
||||
func() {
|
||||
defer func() { recovered = recover() }()
|
||||
err = services.DB.InTx(txCtx, nil, func(tx *sql.Tx) error {
|
||||
users := repository.NewMySQLUsers(tx)
|
||||
if _, err := users.Create(txCtx, model.UserInput{Name: "transaction", Email: email}); err != nil { return err }
|
||||
switch mode {
|
||||
case "error": return abort
|
||||
case "panic": panic(abort)
|
||||
case "cancel": cancel(); return txCtx.Err()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
switch mode {
|
||||
case "commit": if err != nil || recovered != nil { t.Fatalf("commit: error=%v panic=%v", err, recovered) }
|
||||
case "error": if !errors.Is(err, abort) { t.Fatalf("lost business error: %v", err) }
|
||||
case "panic": if recovered != abort { t.Fatalf("lost panic: %v", recovered) }
|
||||
case "cancel": if !errors.Is(err, context.Canceled) { t.Fatalf("lost cancellation: %v", err) }
|
||||
}
|
||||
var count int
|
||||
if err := services.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE email = ?", email).Scan(&count); err != nil { t.Fatal(err) }
|
||||
want := 0
|
||||
if mode == "commit" { want = 1 }
|
||||
if count != want { t.Fatalf("%s persisted %d rows, want %d", mode, count, want) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabasePoolHealthAndClose(t *testing.T) {
|
||||
services := databaseContext(t)
|
||||
ctx := context.Background()
|
||||
if result := services.Health.Check(ctx); !result.Ready() || len(result.Checks) != 1 { t.Fatalf("missing database readiness: %+v", result) }
|
||||
|
||||
cfg := services.DB.Config()
|
||||
cfg.MaxOpenConns, cfg.MaxIdleConns = 1, 0
|
||||
pool, err := database.Open(ctx, cfg)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if idle := pool.Stats().Idle; idle != 0 { t.Fatalf("zero idle config retained %d connections", idle) }
|
||||
_ = pool.Close()
|
||||
|
||||
cfg.MaxIdleConns, cfg.PingTimeout = 1, 50*time.Millisecond
|
||||
pool, err = database.Open(ctx, cfg)
|
||||
if err != nil { t.Fatal(err) }
|
||||
defer pool.Close()
|
||||
connection, err := pool.Conn(ctx)
|
||||
if err != nil { t.Fatal(err) }
|
||||
defer connection.Close()
|
||||
started := time.Now()
|
||||
err = pool.Health(ctx)
|
||||
if !errors.Is(err, context.DeadlineExceeded) || time.Since(started) > time.Second { t.Fatalf("health did not bound pool waiting: %v", err) }
|
||||
|
||||
if err := services.Close(); err != nil { t.Fatal(err) }
|
||||
if err := services.Close(); err != nil { t.Fatal(err) }
|
||||
if result := services.Health.Check(ctx); result.Ready() { t.Fatal("closed database was still ready") }
|
||||
}
|
||||
@@ -3,6 +3,8 @@ module {{.Module}}
|
||||
go 1.22
|
||||
|
||||
require git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}
|
||||
{{if eq .Database "mysql"}}require github.com/go-sql-driver/mysql v1.9.3
|
||||
{{end}}
|
||||
{{- if .FrameworkReplace}}
|
||||
replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}}
|
||||
{{- end}}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni"
|
||||
"{{.Module}}/internal/model"
|
||||
"{{.Module}}/internal/service"
|
||||
)
|
||||
@@ -12,20 +13,109 @@ type Users struct {
|
||||
service *service.Users
|
||||
}
|
||||
|
||||
func NewUsers(service *service.Users) *Users { return &Users{service: service} }
|
||||
func NewUsers(service *service.Users) *Users {
|
||||
return &Users{service: service}
|
||||
}
|
||||
|
||||
func (h *Users) RegisterRoutes(application *omni.App) {
|
||||
application.POST("/users", h.Create)
|
||||
users := application.Group("/users")
|
||||
users.POST("", h.Create)
|
||||
users.GET("", h.List)
|
||||
users.GET("/:id", h.Get)
|
||||
users.PUT("/:id", h.Update)
|
||||
users.DELETE("/:id", h.Delete)
|
||||
}
|
||||
|
||||
// HTTP-specific validation tags stay here, outside the business model.
|
||||
type userBody struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
}
|
||||
|
||||
type userPath struct {
|
||||
ID int64 `uri:"id" binding:"gt=0"`
|
||||
}
|
||||
|
||||
type listQuery struct {
|
||||
Page int `form:"page,default=1" binding:"gte=1"`
|
||||
PageSize int `form:"pageSize,default=20" binding:"gte=1,lte=100"`
|
||||
}
|
||||
|
||||
func (h *Users) Create(ctx *omni.Context) error {
|
||||
var input model.UserInput
|
||||
if err := ctx.BindJSON(&input); err != nil {
|
||||
var body userBody
|
||||
if err := ctx.BindJSON(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := h.service.Create(ctx.Context(), input)
|
||||
user, err := h.service.Create(ctx.Request().Context(), model.UserInput(body))
|
||||
if err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.Created(user)
|
||||
}
|
||||
|
||||
func (h *Users) Get(ctx *omni.Context) error {
|
||||
var path userPath
|
||||
if err := ctx.BindURI(&path); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := h.service.Get(ctx.Request().Context(), path.ID)
|
||||
if err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.OK(user)
|
||||
}
|
||||
|
||||
func (h *Users) List(ctx *omni.Context) error {
|
||||
var query listQuery
|
||||
if err := ctx.BindQuery(&query); err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := h.service.List(ctx.Request().Context(), query.Page, query.PageSize)
|
||||
if err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.OK(page)
|
||||
}
|
||||
|
||||
func (h *Users) Update(ctx *omni.Context) error {
|
||||
var path userPath
|
||||
if err := ctx.BindURI(&path); err != nil {
|
||||
return err
|
||||
}
|
||||
var body userBody
|
||||
if err := ctx.BindJSON(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := h.service.Update(ctx.Request().Context(), path.ID, model.UserInput(body))
|
||||
if err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.OK(user)
|
||||
}
|
||||
|
||||
func (h *Users) Delete(ctx *omni.Context) error {
|
||||
var path userPath
|
||||
if err := ctx.BindURI(&path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.service.Delete(ctx.Request().Context(), path.ID); err != nil {
|
||||
return userError(err)
|
||||
}
|
||||
return ctx.NoContent()
|
||||
}
|
||||
|
||||
func userError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrUserNotFound):
|
||||
return omni.NotFound("USER_NOT_FOUND", "user was not found").WithCause(err)
|
||||
case errors.Is(err, model.ErrEmailTaken):
|
||||
return omni.NewError(http.StatusConflict, "EMAIL_TAKEN", "email is already in use").WithCause(err)
|
||||
case errors.Is(err, model.ErrInvalidUser):
|
||||
return omni.BadRequest("INVALID_USER", "name or email is invalid").WithCause(err)
|
||||
case errors.Is(err, model.ErrInvalidPage):
|
||||
return omni.BadRequest("INVALID_QUERY", "pagination is invalid").WithCause(err)
|
||||
default:
|
||||
// GoOmni masks unexpected errors; internal details stay in server logs.
|
||||
return err
|
||||
}
|
||||
return ctx.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
11
cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl
Normal file
11
cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain errors are translated into HTTP responses only by the handler layer.
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrEmailTaken = errors.New("email already in use")
|
||||
ErrInvalidUser = errors.New("invalid user")
|
||||
ErrInvalidPage = errors.New("invalid pagination")
|
||||
)
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User is the business entity; it has no dependency on HTTP or a database driver.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -10,7 +11,15 @@ type User struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// UserInput is shared by creation and full replacement (PUT).
|
||||
type UserInput struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
type UserPage struct {
|
||||
Items []User `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
@@ -2,30 +2,125 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"{{.Module}}/internal/model"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(context.Context, model.UserInput) (model.User, error)
|
||||
}
|
||||
|
||||
// MemoryUsers stores data only for this process. Replace it with a database
|
||||
// implementation of UserRepository when persistence is required.
|
||||
type MemoryUsers struct {
|
||||
mu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
users map[int64]model.User
|
||||
emails map[string]int64
|
||||
nextID int64
|
||||
users []model.User
|
||||
}
|
||||
|
||||
func NewMemoryUsers() *MemoryUsers { return &MemoryUsers{nextID: 1} }
|
||||
func NewMemoryUsers() *MemoryUsers {
|
||||
return &MemoryUsers{
|
||||
users: make(map[int64]model.User),
|
||||
emails: make(map[string]int64),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Create(_ context.Context, input model.UserInput) (model.User, error) {
|
||||
func (r *MemoryUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
if _, exists := r.emails[input.Email]; exists {
|
||||
return model.User{}, model.ErrEmailTaken
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user := model.User{ID: r.nextID, Name: input.Name, Email: input.Email, CreatedAt: now, UpdatedAt: now}
|
||||
user := model.User{
|
||||
ID: r.nextID, Name: input.Name, Email: input.Email,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
r.nextID++
|
||||
r.users = append(r.users, user)
|
||||
r.users[user.ID] = user
|
||||
r.emails[user.Email] = user.ID
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return model.UserPage{}, model.ErrInvalidPage
|
||||
}
|
||||
r.mu.RLock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
r.mu.RUnlock()
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
users := make([]model.User, 0, len(r.users))
|
||||
for _, user := range r.users {
|
||||
users = append(users, user)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// A stable order is essential: Go map iteration order changes between calls.
|
||||
sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID })
|
||||
result := model.UserPage{
|
||||
Items: []model.User{}, Total: len(users), Page: page, PageSize: pageSize,
|
||||
}
|
||||
// Check the page before multiplying so even a very large page cannot overflow.
|
||||
if len(users) == 0 || page-1 > (len(users)-1)/pageSize {
|
||||
return result, nil
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
count := min(pageSize, len(users)-start)
|
||||
result.Items = users[start : start+count]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
if owner, exists := r.emails[input.Email]; exists && owner != id {
|
||||
return model.User{}, model.ErrEmailTaken
|
||||
}
|
||||
delete(r.emails, user.Email)
|
||||
user.Name, user.Email = input.Name, input.Email
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
r.users[id] = user
|
||||
r.emails[user.Email] = id
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MemoryUsers) Delete(ctx context.Context, id int64) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return model.ErrUserNotFound
|
||||
}
|
||||
delete(r.users, id)
|
||||
delete(r.emails, user.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
139
cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl
Normal file
139
cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl
Normal file
@@ -0,0 +1,139 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"{{.Module}}/internal/model"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// SQLExecutor is implemented by both *sql.DB and *sql.Tx. A repository created
|
||||
// with a transaction keeps all of its queries on that same transaction.
|
||||
type SQLExecutor interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
// MySQLUsers is the persistent implementation of UserRepository.
|
||||
// The schema is documented in schema.sql; migrations stay outside the framework.
|
||||
type MySQLUsers struct {
|
||||
db SQLExecutor
|
||||
}
|
||||
|
||||
func NewMySQLUsers(db SQLExecutor) *MySQLUsers {
|
||||
return &MySQLUsers{db: db}
|
||||
}
|
||||
|
||||
// CheckSchema validates that the application's migration has been applied.
|
||||
// Runtime startup never creates or alters tables.
|
||||
func (r *MySQLUsers) CheckSchema(ctx context.Context) error {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, name, email, created_at, updated_at FROM users LIMIT 0`)
|
||||
if err != nil { return err }
|
||||
return rows.Close()
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO users (name, email, created_at, updated_at)
|
||||
VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, input.Name, input.Email)
|
||||
if err != nil {
|
||||
return model.User{}, mapMySQLError(err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, email, created_at, updated_at
|
||||
FROM users WHERE id = ?`, id).Scan(
|
||||
&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 { return model.UserPage{}, model.ErrInvalidPage }
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
result := model.UserPage{Items: []model.User{}, Total: total, Page: page, PageSize: pageSize}
|
||||
if total == 0 || page-1 > (total-1)/pageSize {
|
||||
return result, nil
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, email, created_at, updated_at
|
||||
FROM users ORDER BY id LIMIT ? OFFSET ?`, pageSize, offset)
|
||||
if err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var user model.User
|
||||
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
result.Items = append(result.Items, user)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users SET name = ?, email = ?, updated_at = UTC_TIMESTAMP(6)
|
||||
WHERE id = ?`, input.Name, input.Email, id)
|
||||
if err != nil {
|
||||
return model.User{}, mapMySQLError(err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
if changed == 0 {
|
||||
// MySQL may report zero affected rows when the replacement is identical;
|
||||
// distinguish that from an ID that does not exist.
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Delete(ctx context.Context, id int64) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted == 0 {
|
||||
return model.ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapMySQLError(err error) error {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return model.ErrEmailTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"{{.Module}}/internal/model"
|
||||
)
|
||||
|
||||
// UserRepository describes the storage operations the business needs.
|
||||
// Implementations must atomically enforce unique, normalized email addresses.
|
||||
type UserRepository interface {
|
||||
Create(context.Context, model.UserInput) (model.User, error)
|
||||
Get(context.Context, int64) (model.User, error)
|
||||
List(context.Context, int, int) (model.UserPage, error)
|
||||
Update(context.Context, int64, model.UserInput) (model.User, error)
|
||||
Delete(context.Context, int64) error
|
||||
}
|
||||
@@ -2,20 +2,61 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"{{.Module}}/internal/model"
|
||||
"{{.Module}}/internal/servicecontext"
|
||||
)
|
||||
|
||||
type Users struct {
|
||||
context *servicecontext.Context
|
||||
services *servicecontext.Context
|
||||
}
|
||||
|
||||
func NewUsers(context *servicecontext.Context) *Users {
|
||||
return &Users{context: context}
|
||||
func NewUsers(services *servicecontext.Context) *Users {
|
||||
return &Users{services: services}
|
||||
}
|
||||
|
||||
func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
// Put business normalization and rules here.
|
||||
return s.context.Users.Create(ctx, input)
|
||||
input, err := normalize(input)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return s.services.Users.Create(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Users) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
return s.services.Users.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Users) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return model.UserPage{}, model.ErrInvalidPage
|
||||
}
|
||||
return s.services.Users.List(ctx, page, pageSize)
|
||||
}
|
||||
|
||||
func (s *Users) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
input, err := normalize(input)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return s.services.Users.Update(ctx, id, input)
|
||||
}
|
||||
|
||||
func (s *Users) Delete(ctx context.Context, id int64) error {
|
||||
return s.services.Users.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func normalize(input model.UserInput) (model.UserInput, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
// This example treats the entire email address as case-insensitive.
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
address, err := mail.ParseAddress(input.Email)
|
||||
if input.Name == "" || utf8.RuneCountInString(input.Name) > 100 ||
|
||||
len(input.Email) > 254 || err != nil || address.Address != input.Email {
|
||||
return model.UserInput{}, model.ErrInvalidUser
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{if eq .Database "mysql"}}"fmt"
|
||||
"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"github.com/go-sql-driver/mysql"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/repository"
|
||||
)
|
||||
@@ -10,8 +16,51 @@ import (
|
||||
type Context struct {
|
||||
Logger *logx.Logger
|
||||
Users repository.UserRepository
|
||||
Health *health.Checker
|
||||
{{if eq .Database "mysql"}}DB *database.DB{{end}}
|
||||
}
|
||||
|
||||
func New(logger *logx.Logger, users repository.UserRepository) *Context {
|
||||
return &Context{Logger: logger, Users: users}
|
||||
// New initializes the project's dependencies before the HTTP listener opens.
|
||||
// The caller owns Logger; Close releases only resources created here.
|
||||
func New(ctx context.Context, logger *logx.Logger) (*Context, error) {
|
||||
if ctx == nil { ctx = context.Background() }
|
||||
if err := ctx.Err(); err != nil { return nil, err }
|
||||
checker, err := health.New()
|
||||
if err != nil { return nil, err }
|
||||
services := &Context{Logger: logger, Health: checker}
|
||||
{{if eq .Database "mysql"}}
|
||||
cfg, err := database.ConfigFromEnv("")
|
||||
if err != nil { return nil, err }
|
||||
if cfg.Driver != "mysql" { return nil, fmt.Errorf("this project requires GO_OMNI_DB_DRIVER=mysql") }
|
||||
dsn, err := mysql.ParseDSN(cfg.DSN)
|
||||
if err != nil { return nil, fmt.Errorf("parse mysql connection settings: %w", err) }
|
||||
if dsn.DBName == "" { return nil, fmt.Errorf("mysql connection settings require a database name") }
|
||||
// DATETIME columns are returned as time.Time in UTC in the business model.
|
||||
dsn.ParseTime = true
|
||||
dsn.Loc = time.UTC
|
||||
cfg.DSN = dsn.FormatDSN()
|
||||
db, err := database.Open(ctx, cfg)
|
||||
if err != nil { return nil, err }
|
||||
users := repository.NewMySQLUsers(db.DB)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
|
||||
defer cancel()
|
||||
if err := users.CheckSchema(checkCtx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("check users schema (apply schema.sql before starting): %w", err)
|
||||
}
|
||||
if err := checker.Add(health.Probe{Name: "database", Timeout: cfg.PingTimeout, Check: db.Health}); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
services.DB, services.Users = db, users
|
||||
{{else}}
|
||||
services.Users = repository.NewMemoryUsers()
|
||||
{{end}}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// Close is safe to call on startup failure and again during application exit.
|
||||
func (c *Context) Close() error {
|
||||
{{if eq .Database "mysql"}}if c != nil && c.DB != nil { return c.DB.Close() }{{end}}
|
||||
return nil
|
||||
}
|
||||
|
||||
9
cmd/go-omni/scaffold/templates/schema.sql.tmpl
Normal file
9
cmd/go-omni/scaffold/templates/schema.sql.tmpl
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE users (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(254) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.1.1"
|
||||
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-]+)*)?$`)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
@@ -11,10 +12,10 @@ const defaultRequestIDHeader = "X-Request-ID"
|
||||
|
||||
// Config controls the default runtime behavior of an App.
|
||||
type Config struct {
|
||||
Mode string
|
||||
Address string
|
||||
RequestIDHeader string
|
||||
EnableLogger bool
|
||||
TrustedProxies []string
|
||||
ShutdownTimeout time.Duration
|
||||
Log logx.Config
|
||||
}
|
||||
@@ -39,6 +40,9 @@ func (c Config) Validate() error {
|
||||
if c.ShutdownTimeout <= 0 {
|
||||
return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"}
|
||||
}
|
||||
if _, err := httpx.NewClientIPResolver(c.TrustedProxies...); err != nil {
|
||||
return &ConfigError{Field: "trusted_proxies", Message: err.Error()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,11 +57,6 @@ func (e *ConfigError) Error() string { return "config " + e.Field + " " + e.Mess
|
||||
// Option changes an App's configuration.
|
||||
type Option func(*Config)
|
||||
|
||||
// WithMode sets the default transport's running mode.
|
||||
func WithMode(mode string) Option {
|
||||
return func(cfg *Config) { cfg.Mode = mode }
|
||||
}
|
||||
|
||||
// WithRequestIDHeader changes the header used to read and return request IDs.
|
||||
func WithRequestIDHeader(header string) Option {
|
||||
return func(cfg *Config) {
|
||||
@@ -72,10 +71,22 @@ func WithLogger(enabled bool) Option {
|
||||
return func(cfg *Config) { cfg.EnableLogger = enabled }
|
||||
}
|
||||
|
||||
// WithTrustedProxies allows only these IPs/CIDRs to supply X-Forwarded-For.
|
||||
// An empty list disables forwarding-header trust. Invalid entries are rejected
|
||||
// by Config.Validate and panic when passed directly to App.New.
|
||||
func WithTrustedProxies(proxies ...string) Option {
|
||||
proxies = append([]string(nil), proxies...)
|
||||
return func(cfg *Config) { cfg.TrustedProxies = append([]string(nil), proxies...) }
|
||||
}
|
||||
|
||||
// WithConfig applies a complete configuration loaded from a file or
|
||||
// environment. Later options can still override individual fields.
|
||||
func WithConfig(settings Config) Option {
|
||||
return func(cfg *Config) { *cfg = settings }
|
||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
return func(cfg *Config) {
|
||||
*cfg = settings
|
||||
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogConfig configures the logger used by the application runtime.
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"GO_OMNI_MODE", "GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
|
||||
"GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
|
||||
"GO_OMNI_TRUSTED_PROXIES",
|
||||
"GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT",
|
||||
"GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
|
||||
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
|
||||
@@ -29,9 +30,9 @@ func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadFromEnvOverridesSettings(t *testing.T) {
|
||||
t.Setenv("GO_OMNI_MODE", "release")
|
||||
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090")
|
||||
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
|
||||
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "127.0.0.1, 10.0.0.0/8")
|
||||
t.Setenv("GO_OMNI_ENABLE_LOGGER", "true")
|
||||
t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
|
||||
t.Setenv("GO_OMNI_LOG_FORMAT", "text")
|
||||
@@ -45,7 +46,7 @@ func TestLoadFromEnvOverridesSettings(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Mode != "release" || cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger {
|
||||
if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger || len(cfg.TrustedProxies) != 2 || cfg.TrustedProxies[1] != "10.0.0.0/8" {
|
||||
t.Fatalf("unexpected application settings: %+v", cfg)
|
||||
}
|
||||
if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource {
|
||||
@@ -66,6 +67,11 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
|
||||
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "proxy.example.com")
|
||||
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trusted_proxies") {
|
||||
t.Fatalf("expected invalid trusted proxy error, got %v", err)
|
||||
}
|
||||
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "")
|
||||
cfg := Default()
|
||||
cfg.Address = ""
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
|
||||
@@ -80,13 +86,17 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
|
||||
func TestWithConfigAndWithLogConfig(t *testing.T) {
|
||||
settings := Default()
|
||||
settings.Mode = "release"
|
||||
settings.Log.Service = "orders"
|
||||
settings.TrustedProxies = []string{"127.0.0.1"}
|
||||
cfg := Default()
|
||||
WithConfig(settings)(&cfg)
|
||||
if cfg.Mode != "release" || cfg.Log.Service != "orders" {
|
||||
if cfg.Log.Service != "orders" || len(cfg.TrustedProxies) != 1 {
|
||||
t.Fatalf("complete config was not applied: %+v", cfg)
|
||||
}
|
||||
settings.TrustedProxies[0] = "10.0.0.0/8"
|
||||
if cfg.TrustedProxies[0] != "127.0.0.1" {
|
||||
t.Fatal("WithConfig should copy trusted proxy settings")
|
||||
}
|
||||
|
||||
WithLogConfig(Default().Log)(&cfg)
|
||||
if cfg.Log.Service != "" {
|
||||
|
||||
@@ -18,9 +18,14 @@ func LoadFromEnv(prefix string) (Config, error) {
|
||||
prefix = defaultEnvPrefix
|
||||
}
|
||||
cfg := Default()
|
||||
cfg.Mode = envString(prefix+"MODE", cfg.Mode)
|
||||
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
|
||||
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" {
|
||||
cfg.TrustedProxies = strings.Split(value, ",")
|
||||
for i := range cfg.TrustedProxies {
|
||||
cfg.TrustedProxies[i] = strings.TrimSpace(cfg.TrustedProxies[i])
|
||||
}
|
||||
}
|
||||
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
|
||||
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
|
||||
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||
- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
||||
- `Health`:用于就绪检查。
|
||||
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
|
||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
|
||||
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
|
||||
|
||||
@@ -18,7 +18,13 @@ import (
|
||||
)
|
||||
|
||||
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := database.Open(context.Background(), cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
```
|
||||
|
||||
@@ -35,3 +41,18 @@ GO_OMNI_DB_PING_TIMEOUT=5s
|
||||
```
|
||||
|
||||
`DSN` 必填,驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。
|
||||
|
||||
`MaxIdleConns=0` 会禁用空闲连接。初始化 Ping 失败会关闭已创建的连接池。
|
||||
|
||||
`go-omni new --database mysql` 会生成项目自己的 MySQL Repository、ServiceContext、
|
||||
数据库就绪探针和 `schema.sql`。核心包不需要知道业务表结构。
|
||||
|
||||
真实数据库验收(需要本地 Docker):
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyDatabase.py --version v0.2.0
|
||||
```
|
||||
|
||||
脚本仅操作本次创建的独立容器与数据库,验证 HTTP CRUD、服务重启持久化、事务提交与
|
||||
错误/panic/取消回滚、连接池关闭、缺少表结构时启动失败,以及停库后的 readiness 503 和恢复后的 200。
|
||||
结束时清理容器、匿名数据卷和临时项目。常规 `go test ./...` 不依赖 Docker。
|
||||
|
||||
@@ -93,6 +93,9 @@ type DB struct {
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, cfg Config) (*DB, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -103,9 +106,9 @@ func Open(ctx context.Context, cfg Config) (*DB, error) {
|
||||
if cfg.MaxOpenConns > 0 {
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
}
|
||||
if cfg.MaxIdleConns > 0 {
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
}
|
||||
// database/sql uses zero to disable idle connections; do not leave its
|
||||
// default pool size in place when the caller explicitly chooses zero.
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
|
||||
@@ -123,7 +126,12 @@ func (db *DB) Health(ctx context.Context) error {
|
||||
if db == nil || db.DB == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
return db.PingContext(ctx)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, db.config.PingTimeout)
|
||||
defer cancel()
|
||||
return db.PingContext(checkCtx)
|
||||
}
|
||||
|
||||
// InTx commits only when work returns nil. Any work error or panic rolls back.
|
||||
|
||||
39
database/lifecycle_test.go
Normal file
39
database/lifecycle_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var failedPingClosed atomic.Int32
|
||||
var errPingUnavailable = errors.New("database unavailable")
|
||||
|
||||
type failedPingDriver struct{}
|
||||
type failedPingConnection struct{}
|
||||
|
||||
func (failedPingDriver) Open(string) (driver.Conn, error) { return failedPingConnection{}, nil }
|
||||
func (failedPingConnection) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (failedPingConnection) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") }
|
||||
func (failedPingConnection) Close() error { failedPingClosed.Add(1); return nil }
|
||||
func (failedPingConnection) Ping(context.Context) error { return errPingUnavailable }
|
||||
|
||||
func init() { sql.Register("goOmniFailPing", failedPingDriver{}) }
|
||||
|
||||
func TestOpenClosesPoolWhenStartupPingFails(t *testing.T) {
|
||||
failedPingClosed.Store(0)
|
||||
cfg := DefaultConfig()
|
||||
cfg.Driver, cfg.DSN = "goOmniFailPing", "test"
|
||||
pool, err := Open(context.Background(), cfg)
|
||||
if pool != nil || !errors.Is(err, errPingUnavailable) {
|
||||
t.Fatalf("unexpected startup result: db=%v err=%v", pool, err)
|
||||
}
|
||||
if closed := failedPingClosed.Load(); closed != 1 {
|
||||
t.Fatalf("startup failure closed %d connections, want 1", closed)
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,8 @@ examples/reference/
|
||||
|
||||
调用路径:HTTP → Handler → Service → Repository。
|
||||
|
||||
- Handler 依赖 GoOmni,示例代码不导入 Gin,也不调用 `Engine()` / `Raw()`。
|
||||
- Service、Repository、Model 不依赖 GoOmni 或 Gin,通过标准 `context.Context` 传递请求取消信号。
|
||||
- Handler 依赖 GoOmni,示例代码不导入具体网络库,也不调用 `Engine()` / `Raw()`。
|
||||
- Service、Repository、Model 不依赖 GoOmni 或网络库,通过标准 `context.Context` 传递请求取消信号。
|
||||
- JSON 校验标签留在 Handler 的请求结构上;业务模型没有 HTTP 绑定标签。
|
||||
- Service 返回业务错误,由 Handler 映射为 HTTP 错误;未知错误交给框架转换成通用 500 响应。
|
||||
- 通过 `application.Group("/api/v1", handler.RequestLog(logger))` 挂载自定义中间件,再注册 `/users` 子路由。日志记录请求 ID、耗时和返回错误;panic 由框架恢复中间件处理。
|
||||
@@ -156,4 +156,6 @@ go vet ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
`TestUserCRUD` 使用标准库适配器运行完整业务注册代码;同一套 Handler、Service、Repository 也可通过 `omni.NewWithRouter` 接入自研适配器。
|
||||
|
||||
测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。
|
||||
|
||||
@@ -20,26 +20,24 @@ import (
|
||||
|
||||
func newApp(users service.UserRepository, logger *logx.Logger, options ...omni.Option) *omni.App {
|
||||
application := omni.New(options...)
|
||||
application.GET("/health", func(ctx *omni.Context) error {
|
||||
return ctx.OK(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
api := application.Group("/api/v1", handler.RequestLog(logger))
|
||||
handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, nil, nil)
|
||||
registerRoutes(application, users, logger, nil, nil)
|
||||
return application
|
||||
}
|
||||
|
||||
func newProtectedApp(users service.UserRepository, logger *logx.Logger, authenticator auth.Authenticator, options ...omni.Option) *omni.App {
|
||||
application := omni.New(options...)
|
||||
registerRoutes(application, users, logger, auth.RequireAuth(authenticator), auth.RequireRole("admin"))
|
||||
return application
|
||||
}
|
||||
|
||||
// Registration and business layers are shared by every network adapter.
|
||||
func registerRoutes(application *omni.App, users service.UserRepository, logger *logx.Logger, write, delete omni.Middleware) {
|
||||
application.GET("/health", func(ctx *omni.Context) error {
|
||||
return ctx.OK(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
api := application.Group("/api/v1", handler.RequestLog(logger))
|
||||
write := auth.RequireAuth(authenticator)
|
||||
delete := auth.RequireRole("admin")
|
||||
handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, write, delete)
|
||||
return application
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
)
|
||||
|
||||
type envelope struct {
|
||||
@@ -29,7 +31,7 @@ type envelope struct {
|
||||
}
|
||||
|
||||
func testApp() *omni.App {
|
||||
return newApp(repository.NewMemoryUsers(), testLogger(io.Discard), omni.WithMode("test"))
|
||||
return newApp(repository.NewMemoryUsers(), testLogger(io.Discard))
|
||||
}
|
||||
|
||||
func testLogger(writer io.Writer) *logx.Logger {
|
||||
@@ -84,7 +86,6 @@ func TestProtectedWriteRoutes(t *testing.T) {
|
||||
repository.NewMemoryUsers(),
|
||||
testLogger(io.Discard),
|
||||
auth.StaticAuthenticator{"user-token": {UserID: "user-1", Roles: []string{"user"}}},
|
||||
omni.WithMode("test"),
|
||||
)
|
||||
request(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, 401, "UNAUTHENTICATED")
|
||||
created := dataAs[model.User](t, requestWithAuthorization(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, "Bearer user-token", 201, "CREATED"))
|
||||
@@ -103,7 +104,22 @@ func dataAs[T any](t *testing.T, value envelope) T {
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
app := testApp()
|
||||
for _, adapter := range []struct {
|
||||
name string
|
||||
newRouter func() httpx.Router
|
||||
}{
|
||||
{"nethttp", func() httpx.Router { return netrouter.New() }},
|
||||
} {
|
||||
t.Run(adapter.name, func(t *testing.T) {
|
||||
application := omni.NewWithRouter(adapter.newRouter())
|
||||
registerRoutes(application, repository.NewMemoryUsers(), testLogger(io.Discard), nil, nil)
|
||||
checkUserCRUD(t, application)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkUserCRUD(t *testing.T, app *omni.App) {
|
||||
t.Helper()
|
||||
health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK"))
|
||||
if health["status"] != "ok" {
|
||||
t.Fatalf("unexpected health: %v", health)
|
||||
@@ -207,7 +223,7 @@ func TestErrorTranslationAndRequestLogging(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
app := newApp(failingUsers{repository.NewMemoryUsers(), test.err}, testLogger(&logs), omni.WithMode("test"))
|
||||
app := newApp(failingUsers{repository.NewMemoryUsers(), test.err}, testLogger(&logs))
|
||||
result := request(t, app, "GET", "/api/v1/users/1", "", test.status, test.code)
|
||||
if strings.Contains(result.Message, test.err.Error()) || len(result.Data) != 0 {
|
||||
t.Fatalf("internal error details leaked: %+v", result)
|
||||
|
||||
21
go.mod
21
go.mod
@@ -3,36 +3,19 @@ module git.yuntuo.hk/k2safe/go-omni
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
golang.org/x/net v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
61
go.sum
61
go.sum
@@ -1,22 +1,9 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -27,67 +14,19 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
105
httpx/clientIP.go
Normal file
105
httpx/clientIP.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientIPResolver trusts X-Forwarded-For only from explicitly configured
|
||||
// proxy addresses. It is immutable and safe to share between requests.
|
||||
// The zero value (and a nil resolver) trusts no proxy.
|
||||
type ClientIPResolver struct {
|
||||
trusted []netip.Prefix
|
||||
}
|
||||
|
||||
// NewClientIPResolver accepts IP addresses or CIDR networks, never hostnames.
|
||||
func NewClientIPResolver(proxies ...string) (*ClientIPResolver, error) {
|
||||
r := &ClientIPResolver{}
|
||||
for _, value := range proxies {
|
||||
value = strings.TrimSpace(value)
|
||||
if address, err := netip.ParseAddr(value); err == nil && address.Zone() == "" {
|
||||
address = address.Unmap()
|
||||
r.trusted = append(r.trusted, netip.PrefixFrom(address, address.BitLen()))
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trusted proxy %q: expected an IP address or CIDR", value)
|
||||
}
|
||||
if prefix.Addr().Is4In6() {
|
||||
if prefix.Bits() < 96 {
|
||||
return nil, fmt.Errorf("invalid trusted proxy %q: mapped IPv4 prefix must be at least /96", value)
|
||||
}
|
||||
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
|
||||
}
|
||||
r.trusted = append(r.trusted, prefix.Masked())
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *ClientIPResolver) trusts(address netip.Addr) bool {
|
||||
if r != nil {
|
||||
for _, prefix := range r.trusted {
|
||||
if prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClientIP walks X-Forwarded-For from right to left, stopping at the first
|
||||
// untrusted address. Missing or malformed forwarding data falls back to the
|
||||
// socket peer. X-Real-IP, Forwarded and provider-specific headers are ignored.
|
||||
func (r *ClientIPResolver) ClientIP(request *http.Request) string {
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
host := request.RemoteAddr
|
||||
if value, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = value
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
peer = peer.WithZone("").Unmap()
|
||||
if !r.trusts(peer) {
|
||||
return peer.String()
|
||||
}
|
||||
values := request.Header.Values("X-Forwarded-For")
|
||||
var chain []netip.Addr
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
address, err := netip.ParseAddr(strings.TrimSpace(part))
|
||||
if err != nil || address.Zone() != "" {
|
||||
return peer.String()
|
||||
}
|
||||
chain = append(chain, address.Unmap())
|
||||
}
|
||||
}
|
||||
current := peer
|
||||
for i := len(chain) - 1; i >= 0 && r.trusts(current); i-- {
|
||||
current = chain[i]
|
||||
}
|
||||
return current.String()
|
||||
}
|
||||
|
||||
type clientIPResolverKey struct{}
|
||||
|
||||
// WithClientIPResolver configures ClientIP for Context values derived from ctx.
|
||||
// Adapters should set this before running application middleware.
|
||||
func WithClientIPResolver(ctx context.Context, resolver *ClientIPResolver) context.Context {
|
||||
return context.WithValue(ctx, clientIPResolverKey{}, resolver)
|
||||
}
|
||||
|
||||
// ClientIP returns the verified client address without a port. By default it
|
||||
// returns the socket peer; App's trusted proxy configuration enables XFF.
|
||||
func (c *Context) ClientIP() string {
|
||||
resolver, _ := c.Context().Value(clientIPResolverKey{}).(*ClientIPResolver)
|
||||
return resolver.ClientIP(c.request)
|
||||
}
|
||||
76
httpx/clientIP_test.go
Normal file
76
httpx/clientIP_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package httpx_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
func TestClientIPResolverUsesSocketPeerByDefault(t *testing.T) {
|
||||
resolver, err := httpx.NewClientIPResolver()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := newClientIPRequest("192.0.2.10:443", "198.51.100.7")
|
||||
if got := resolver.ClientIP(request); got != "192.0.2.10" {
|
||||
t.Fatalf("untrusted forwarding header changed client IP: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPResolverWalksTrustedForwardingChain(t *testing.T) {
|
||||
resolver, err := httpx.NewClientIPResolver("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := newClientIPRequest("10.0.0.5:443", "198.51.100.7, 10.0.0.4")
|
||||
if got := resolver.ClientIP(request); got != "198.51.100.7" {
|
||||
t.Fatalf("expected original client IP, got %q", got)
|
||||
}
|
||||
|
||||
request = newClientIPRequest("10.0.0.5:443", "198.51.100.7, 203.0.113.9, 10.0.0.4")
|
||||
if got := resolver.ClientIP(request); got != "203.0.113.9" {
|
||||
t.Fatalf("expected first untrusted hop, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPResolverRejectsMalformedForwardingData(t *testing.T) {
|
||||
resolver, err := httpx.NewClientIPResolver("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := newClientIPRequest("127.0.0.1:443", "198.51.100.7, not-an-ip")
|
||||
if got := resolver.ClientIP(request); got != "127.0.0.1" {
|
||||
t.Fatalf("malformed forwarding header should fall back to peer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientIPResolverRejectsHostnames(t *testing.T) {
|
||||
if _, err := httpx.NewClientIPResolver("proxy.example.com"); err == nil || !strings.Contains(err.Error(), "invalid trusted proxy") {
|
||||
t.Fatalf("expected hostname validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextClientIPUsesResolverFromRequestContext(t *testing.T) {
|
||||
resolver, err := httpx.NewClientIPResolver("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := newClientIPRequest("127.0.0.1:443", "198.51.100.7")
|
||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), resolver))
|
||||
ctx := httpx.NewContext(request, httptest.NewRecorder(), nil)
|
||||
if got := ctx.ClientIP(); got != "198.51.100.7" {
|
||||
t.Fatalf("expected context client IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newClientIPRequest(remote, forwarded string) *http.Request {
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = remote
|
||||
if forwarded != "" {
|
||||
request.Header.Set("X-Forwarded-For", forwarded)
|
||||
}
|
||||
return request
|
||||
}
|
||||
@@ -18,9 +18,11 @@ type Handler func(*Context) error
|
||||
type Middleware func(Handler) Handler
|
||||
|
||||
// Router is the small contract the application needs from a network layer.
|
||||
// The default implementation is backed by Gin, but another router or a
|
||||
// future GoOmni transport can implement this interface without changing
|
||||
// business handlers.
|
||||
// The default implementation is backed by the standard library, but another
|
||||
// router or a future GoOmni transport can implement this interface without
|
||||
// changing business handlers. Register routes before serving requests. Implementations
|
||||
// should pass transport/transporttest.Run; see transport/README.md for the
|
||||
// portable path, method, fallback and context-propagation rules.
|
||||
type Router interface {
|
||||
http.Handler
|
||||
Handle(method, path string, handler http.Handler)
|
||||
@@ -76,7 +78,8 @@ func RoutePattern(ctx context.Context) string {
|
||||
}
|
||||
|
||||
// Context provides the business-facing request and response API. It contains
|
||||
// only standard-library HTTP types, so it remains usable when Gin is replaced.
|
||||
// only standard-library HTTP types, so it remains usable when the network
|
||||
// implementation is replaced.
|
||||
type Context struct {
|
||||
request *http.Request
|
||||
writer http.ResponseWriter
|
||||
@@ -162,16 +165,25 @@ func (c *Context) BindURI(value any) error { return validate.URI(c.params, value
|
||||
|
||||
// JSON is an escape hatch for responses that do not use the standard envelope.
|
||||
func (c *Context) JSON(status int, value any) error {
|
||||
if c.responded {
|
||||
return nil
|
||||
}
|
||||
if c.writer == nil {
|
||||
return apierrors.Internal(nil)
|
||||
}
|
||||
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.writer.WriteHeader(status)
|
||||
if err := json.NewEncoder(c.writer).Encode(value); err != nil {
|
||||
// Encode before committing headers so an invalid value can still produce
|
||||
// the standard 500 response on every adapter.
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return apierrors.Internal(err)
|
||||
}
|
||||
c.responded = true
|
||||
c.status = status
|
||||
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.writer.WriteHeader(status)
|
||||
if _, err := c.writer.Write(append(data, '\n')); err != nil {
|
||||
return apierrors.Internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -186,9 +198,15 @@ func (c *Context) Accepted(data any) error {
|
||||
}
|
||||
|
||||
func (c *Context) NoContent() error {
|
||||
c.writer.WriteHeader(http.StatusNoContent)
|
||||
if c.responded {
|
||||
return nil
|
||||
}
|
||||
if c.writer == nil {
|
||||
return apierrors.Internal(nil)
|
||||
}
|
||||
c.responded = true
|
||||
c.status = http.StatusNoContent
|
||||
c.writer.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -213,7 +231,8 @@ func (c *Context) Status() int {
|
||||
func (c *Context) Responded() bool { return c.responded }
|
||||
|
||||
// WriteError writes a stable public error response. Internal causes are not
|
||||
// serialized and a partially written response is preserved.
|
||||
// serialized. Responses committed through Context methods are preserved;
|
||||
// direct Raw writes are outside this response-tracking contract.
|
||||
func (c *Context) WriteError(err error) {
|
||||
if c.responded {
|
||||
return
|
||||
|
||||
696
internal/perfcheck/main.go
Normal file
696
internal/perfcheck/main.go
Normal file
@@ -0,0 +1,696 @@
|
||||
// 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"`
|
||||
ActiveRequests int64 `json:"activeRequests,omitempty"`
|
||||
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"})
|
||||
})
|
||||
application.GET("/slow", func(c *omni.Context) error {
|
||||
timer := time.NewTimer(2 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return c.OK(map[string]string{"status": "slow"})
|
||||
case <-c.Context().Done():
|
||||
return c.Context().Err()
|
||||
}
|
||||
})
|
||||
done := make(chan error, 1)
|
||||
var shutdown func(context.Context) error
|
||||
var stats func() native.Stats
|
||||
switch adapter {
|
||||
case "native":
|
||||
server := native.New(application)
|
||||
server.MaxConnections = 8192
|
||||
server.MaxConcurrentRequests = 4096
|
||||
shutdown = server.Shutdown
|
||||
stats = server.Stats
|
||||
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)
|
||||
connections, accepted, activeRequests := tracked.active.Load(), tracked.accepted.Load(), int64(0)
|
||||
if stats != nil {
|
||||
value := stats()
|
||||
connections, accepted, activeRequests = value.ActiveConnections, int64(value.AcceptedConnections), value.ActiveRequests
|
||||
}
|
||||
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: connections, Accepted: accepted, ActiveRequests: activeRequests,
|
||||
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...)
|
||||
}
|
||||
75
internal/perfcheck/main_test.go
Normal file
75
internal/perfcheck/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# logx
|
||||
|
||||
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖 Gin:
|
||||
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖具体网络库:
|
||||
|
||||
```go
|
||||
import "git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package logx provides the application logger used by GoOmni and business
|
||||
// packages. Its public API is deliberately independent of Gin so the same
|
||||
// packages. Its public API is deliberately independent of the network layer so the same
|
||||
// logger can be used from HTTP handlers, jobs and command line programs.
|
||||
package logx
|
||||
|
||||
|
||||
106
metrics/metrics.go
Normal file
106
metrics/metrics.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package metrics provides a small transport-neutral request metrics collector.
|
||||
// It is intentionally in-memory and dependency-free; applications can expose
|
||||
// Snapshot through any route or export it to their monitoring system.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
// Snapshot is a point-in-time view of request metrics collected by Metrics.
|
||||
type Snapshot struct {
|
||||
Requests uint64 `json:"requests"`
|
||||
Errors uint64 `json:"errors"`
|
||||
InFlight int64 `json:"inFlight"`
|
||||
TotalDurationMs float64 `json:"totalDurationMs"`
|
||||
StatusCounts map[string]uint64 `json:"statusCounts"`
|
||||
}
|
||||
|
||||
// Metrics collects request counts, status counts, in-flight requests and
|
||||
// cumulative handler time. A zero Metrics value is ready for use.
|
||||
type Metrics struct {
|
||||
requests atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
inFlight atomic.Int64
|
||||
durationNs atomic.Int64
|
||||
statuses [600]atomic.Uint64
|
||||
}
|
||||
|
||||
// New creates an empty metrics collector.
|
||||
func New() *Metrics { return &Metrics{} }
|
||||
|
||||
// Middleware records every request that reaches the middleware chain. Errors
|
||||
// include returned handler errors and responses with a status of 400 or greater.
|
||||
func (m *Metrics) Middleware() httpx.Middleware {
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(ctx *httpx.Context) error {
|
||||
if m == nil {
|
||||
return next(ctx)
|
||||
}
|
||||
m.inFlight.Add(1)
|
||||
started := time.Now()
|
||||
err := next(ctx)
|
||||
status := ctx.Status()
|
||||
if err != nil && !ctx.Responded() {
|
||||
status = apierrors.From(err).Status
|
||||
}
|
||||
m.requests.Add(1)
|
||||
if err != nil || status >= 400 {
|
||||
m.errors.Add(1)
|
||||
}
|
||||
if status >= 0 && status < len(m.statuses) {
|
||||
m.statuses[status].Add(1)
|
||||
}
|
||||
m.durationNs.Add(time.Since(started).Nanoseconds())
|
||||
m.inFlight.Add(-1)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler exposes the current snapshot through the standard GoOmni response
|
||||
// envelope. Register it explicitly, for example app.GET("/metrics", m.Handler()).
|
||||
func (m *Metrics) Handler() httpx.Handler {
|
||||
return func(ctx *httpx.Context) error {
|
||||
return ctx.OK(m.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns a consistent-enough point-in-time view for operational
|
||||
// reporting. Counters may advance while the snapshot is being assembled.
|
||||
func (m *Metrics) Snapshot() Snapshot {
|
||||
if m == nil {
|
||||
return Snapshot{StatusCounts: map[string]uint64{}}
|
||||
}
|
||||
statuses := make(map[string]uint64)
|
||||
for status := 100; status < len(m.statuses); status++ {
|
||||
if count := m.statuses[status].Load(); count > 0 {
|
||||
statuses[itoa(status)] = count
|
||||
}
|
||||
}
|
||||
return Snapshot{
|
||||
Requests: m.requests.Load(),
|
||||
Errors: m.errors.Load(),
|
||||
InFlight: m.inFlight.Load(),
|
||||
TotalDurationMs: float64(m.durationNs.Load()) / float64(time.Millisecond),
|
||||
StatusCounts: statuses,
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var digits [3]byte
|
||||
index := len(digits)
|
||||
for value > 0 {
|
||||
index--
|
||||
digits[index] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits[index:])
|
||||
}
|
||||
67
metrics/metrics_test.go
Normal file
67
metrics/metrics_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/metrics"
|
||||
)
|
||||
|
||||
func TestMiddlewareCollectsStatusesAndErrors(t *testing.T) {
|
||||
collector := metrics.New()
|
||||
request := httptest.NewRequest("GET", "/health", nil)
|
||||
first := httptest.NewRecorder()
|
||||
ctx := httpx.NewContext(request, first, nil)
|
||||
if err := collector.Middleware()(func(c *httpx.Context) error { return c.OK("ok") })(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
ctx = httpx.NewContext(request, second, nil)
|
||||
handled := collector.Middleware()(func(*httpx.Context) error {
|
||||
return apierrors.NotFound("MISSING", "missing")
|
||||
})
|
||||
if err := handled(ctx); err == nil {
|
||||
t.Fatal("expected handler error")
|
||||
}
|
||||
|
||||
snapshot := collector.Snapshot()
|
||||
if snapshot.Requests != 2 || snapshot.Errors != 1 || snapshot.InFlight != 0 || snapshot.StatusCounts["200"] != 1 || snapshot.StatusCounts["404"] != 1 {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReturnsJSONSnapshot(t *testing.T) {
|
||||
collector := metrics.New()
|
||||
request := httptest.NewRequest("GET", "/metrics", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx := httpx.NewContext(request, recorder, nil)
|
||||
if err := collector.Handler()(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Data metrics.Snapshot `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Code != "OK" || envelope.Data.StatusCounts == nil {
|
||||
t.Fatalf("unexpected metrics response: %s", recorder.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewarePreservesApplicationErrors(t *testing.T) {
|
||||
collector := metrics.New()
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
ctx := httpx.NewContext(request, httptest.NewRecorder(), nil)
|
||||
want := errors.New("database unavailable")
|
||||
got := collector.Middleware()(func(*httpx.Context) error { return want })(ctx)
|
||||
if !errors.Is(got, want) {
|
||||
t.Fatalf("middleware replaced error: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
@@ -31,8 +30,8 @@ func RequestID(header string) httpx.Middleware {
|
||||
}
|
||||
|
||||
// Recovery converts panics from handlers and middleware into the standard
|
||||
// internal error. The network adapter does not need a Gin-specific recovery
|
||||
// hook for this behavior.
|
||||
// internal error. Recovery belongs to GoOmni's transport-neutral middleware
|
||||
// rather than a router-specific hook.
|
||||
func Recovery() httpx.Middleware {
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(ctx *httpx.Context) (err error) {
|
||||
@@ -55,7 +54,7 @@ func AccessLog() httpx.Middleware {
|
||||
started := time.Now()
|
||||
err := next(ctx)
|
||||
status := ctx.Status()
|
||||
if err != nil {
|
||||
if err != nil && !ctx.Responded() {
|
||||
status = apierrors.From(err).Status
|
||||
}
|
||||
logx.InfoContext(ctx.Context(), "http.request",
|
||||
@@ -64,19 +63,9 @@ func AccessLog() httpx.Middleware {
|
||||
"route", httpx.RoutePattern(ctx.Context()),
|
||||
"status", status,
|
||||
"durationMs", time.Since(started).Seconds()*1000,
|
||||
"clientIp", clientIP(ctx.Request()),
|
||||
"clientIp", ctx.ClientIP(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clientIP(request *http.Request) string {
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
if value := request.Header.Get("X-Forwarded-For"); value != "" {
|
||||
return value
|
||||
}
|
||||
return request.RemoteAddr
|
||||
}
|
||||
|
||||
88
middleware/middleware_test.go
Normal file
88
middleware/middleware_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
)
|
||||
|
||||
func TestAccessLogReportsCommittedStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
handler httpx.Handler
|
||||
status int
|
||||
}{
|
||||
{"unwritten error", func(c *httpx.Context) error { return apierrors.NotFound("MISSING", "missing") }, 404},
|
||||
{"encoding error", func(c *httpx.Context) error { return c.OK(make(chan int)) }, 500},
|
||||
{"committed error", func(c *httpx.Context) error { _ = c.Created("saved"); return errors.New("later error") }, 201},
|
||||
{"committed panic", func(c *httpx.Context) error { _ = c.Accepted("saved"); panic("later panic") }, 202},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger, err := logx.New(logx.Config{Format: "json", Writer: &output})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logx.SetDefault(logger)
|
||||
defer logx.SetDefault(nil)
|
||||
req := httptest.NewRequest("GET", "/users/42", nil)
|
||||
req.Header.Set("X-Request-ID", "logRequest")
|
||||
req = req.WithContext(httpx.WithRoutePattern(req.Context(), "/users/:id"))
|
||||
w := httptest.NewRecorder()
|
||||
ctx := httpx.NewContext(req, w, nil)
|
||||
chain := middleware.RequestID("X-Request-ID")(middleware.AccessLog()(middleware.Recovery()(tc.handler)))
|
||||
if err := chain(ctx); err != nil {
|
||||
ctx.WriteError(err)
|
||||
}
|
||||
var entry struct {
|
||||
Status int
|
||||
Route string
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
if err := json.Unmarshal(output.Bytes(), &entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Status != tc.status || w.Code != tc.status || entry.Route != "/users/:id" || entry.RequestID != "logRequest" {
|
||||
t.Fatalf("log=%s response=%d; expected status %d and request metadata", &output, w.Code, tc.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLogUsesVerifiedClientIP(t *testing.T) {
|
||||
loggerOutput := bytes.Buffer{}
|
||||
logger, err := logx.New(logx.Config{Format: "json", Writer: &loggerOutput})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logx.SetDefault(logger)
|
||||
defer logx.SetDefault(nil)
|
||||
resolver, err := httpx.NewClientIPResolver("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest("GET", "/health", nil)
|
||||
request.RemoteAddr = "127.0.0.1:443"
|
||||
request.Header.Set("X-Forwarded-For", "198.51.100.7")
|
||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), resolver))
|
||||
ctx := httpx.NewContext(request, httptest.NewRecorder(), nil)
|
||||
if err := middleware.AccessLog()(func(c *httpx.Context) error { return c.OK("ok") })(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var entry struct {
|
||||
ClientIP string `json:"clientIp"`
|
||||
}
|
||||
if err := json.Unmarshal(loggerOutput.Bytes(), &entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.ClientIP != "198.51.100.7" {
|
||||
t.Fatalf("expected verified client IP, got %q", entry.ClientIP)
|
||||
}
|
||||
}
|
||||
9
omni.go
9
omni.go
@@ -9,10 +9,12 @@ import (
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/metrics"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
type App = app.App
|
||||
type Runner = app.Runner
|
||||
type RouterGroup = app.RouterGroup
|
||||
type Principal = auth.Principal
|
||||
type Authenticator = auth.Authenticator
|
||||
@@ -27,6 +29,7 @@ type Option = config.Option
|
||||
type Response = response.Response
|
||||
type LogConfig = logx.Config
|
||||
type Logger = logx.Logger
|
||||
type Metrics = metrics.Metrics
|
||||
|
||||
func New(options ...Option) *App { return app.New(options...) }
|
||||
|
||||
@@ -34,12 +37,12 @@ func NewWithRouter(router Router, options ...Option) *App {
|
||||
return app.NewWithRouter(router, options...)
|
||||
}
|
||||
|
||||
func WithMode(mode string) Option { return config.WithMode(mode) }
|
||||
|
||||
func WithRequestIDHeader(header string) Option { return config.WithRequestIDHeader(header) }
|
||||
|
||||
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
|
||||
|
||||
func WithTrustedProxies(proxies ...string) Option { return config.WithTrustedProxies(proxies...) }
|
||||
|
||||
func WithConfig(settings Config) Option { return config.WithConfig(settings) }
|
||||
|
||||
func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) }
|
||||
@@ -50,6 +53,8 @@ func LoadConfig(prefix string) (Config, error) { return config.LoadFromEnv(prefi
|
||||
|
||||
func SetupLogger(cfg LogConfig) (func() error, error) { return logx.Setup(cfg) }
|
||||
|
||||
func NewMetrics() *Metrics { return metrics.New() }
|
||||
|
||||
func NewError(status int, code, message string) *APIError {
|
||||
return apierrors.New(status, code, message)
|
||||
}
|
||||
|
||||
80
performance/http2FlowControlLinux.json
Normal file
80
performance/http2FlowControlLinux.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"testedCommit": "fa2be5e0166f1fa581a2b0c7ee2fc29c44ac2676",
|
||||
"startedAt": "2026-09-23T02:50:32.502669+00:00",
|
||||
"imageId": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"command": [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-boundary-363c306dd46d",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"-v",
|
||||
"/root/go-omni-validation:/workspace:ro",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go test -race -count=1 -timeout 2m -v ./transport/http2 && go vet ./transport/http2 && go build ./transport/http2"
|
||||
],
|
||||
"wallTimeoutSeconds": 300,
|
||||
"finishedAt": "2026-09-23T02:51:28.854252+00:00",
|
||||
"exitCode": 0,
|
||||
"logSha256": "52c96133d72ac15fa13b5784ba045a732d5ed0cf728f111481f0046407efff81",
|
||||
"platform": "linux/amd64",
|
||||
"goVersion": "go1.25.14",
|
||||
"package": "./transport/http2",
|
||||
"checks": [
|
||||
"race",
|
||||
"vet",
|
||||
"build"
|
||||
],
|
||||
"passedTests": [
|
||||
"TestTLSConnectionLimitRecoversAfterHandshakeTimeout",
|
||||
"TestSlowResponseStreamIsolationAndRecovery",
|
||||
"TestConnectionWindowIsolationAndRecovery",
|
||||
"TestShutdownReleasesFlowControlledWriter",
|
||||
"TestShutdownSendsGoAwayAndDrains",
|
||||
"TestRSTStreamCancelsOnlyOneRequest",
|
||||
"TestCanceledServeContextWaitsForShutdownDeadline",
|
||||
"TestTLSMixedSoak",
|
||||
"TestServerHandlesH2CRequest",
|
||||
"TestServerEnforcesGlobalRequestLimitAndRecovers",
|
||||
"TestServerRejectsBodyAboveLimitAndRecovers",
|
||||
"TestServerRejectsHeadersAboveLimitAndRecovers",
|
||||
"TestServerMultiplexes128Streams",
|
||||
"TestServerStreamsLargeBodyWithFlowControl",
|
||||
"TestServerTLSUsesH2ALPN",
|
||||
"TestTLSSoak"
|
||||
],
|
||||
"notes": [
|
||||
"Sequentially run after the 30-minute mixed soak; limited to 2 CPUs, 2048 MiB RAM with no swap, and 256 tasks.",
|
||||
"The working tree was clean at testedCommit and mounted read-only. The supervisor enforced a 300-second wall timeout and removed only its own container.",
|
||||
"Malformed-frame and header-limit tests deliberately trigger HTTP/2 error logs; each corresponding test passed.",
|
||||
"Race-detector memory in this regression log is not a production memory baseline. Ordinary-mode mixed-soak measurements are recorded separately."
|
||||
],
|
||||
"postRunCheck": {
|
||||
"checkedAt": "2026-09-23T02:53:33.308077+00:00",
|
||||
"preExistingContainersStillRunning": 7,
|
||||
"remainingHTTP2ValidationContainers": []
|
||||
}
|
||||
}
|
||||
53
performance/http2FlowControlLinux.txt
Normal file
53
performance/http2FlowControlLinux.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestTLSConnectionLimitRecoversAfterHandshakeTimeout
|
||||
--- PASS: TestTLSConnectionLimitRecoversAfterHandshakeTimeout (1.12s)
|
||||
=== RUN TestSlowResponseStreamIsolationAndRecovery
|
||||
--- PASS: TestSlowResponseStreamIsolationAndRecovery (0.00s)
|
||||
=== RUN TestConnectionWindowIsolationAndRecovery
|
||||
--- PASS: TestConnectionWindowIsolationAndRecovery (0.00s)
|
||||
=== RUN TestShutdownReleasesFlowControlledWriter
|
||||
--- PASS: TestShutdownReleasesFlowControlledWriter (0.10s)
|
||||
=== RUN TestShutdownSendsGoAwayAndDrains
|
||||
--- PASS: TestShutdownSendsGoAwayAndDrains (0.00s)
|
||||
=== RUN TestRSTStreamCancelsOnlyOneRequest
|
||||
--- PASS: TestRSTStreamCancelsOnlyOneRequest (0.00s)
|
||||
=== RUN TestCanceledServeContextWaitsForShutdownDeadline
|
||||
--- PASS: TestCanceledServeContextWaitsForShutdownDeadline (0.10s)
|
||||
=== RUN TestTLSMixedSoak
|
||||
mixedSoak_test.go:37: phase=before elapsed=1ms requests=0 failures=0 heapBytes=2409792 rssBytes=37199872 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:0 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
mixedSoak_test.go:38: scenario=mixed connections=8 workersPerConnection=16 uploadBytes=262144 uploadChunkBytes=4096 uploadDelay=2ms downloadBytes=8388608 downloadChunkBytes=65536 downloadDelay=20ms
|
||||
mixedSoak_test.go:190: phase=after elapsed=5.655s requests=10489 failures=0 heapBytes=62134360 rssBytes=470847488 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:4 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
mixedSoak_test.go:124: mixedTotals phase=after health=10356 slowUploads=117 slowDownloads=16 uploadedBytes=30670848 downloadedBytes=134217728 serverFailures=0
|
||||
mixedSoak_test.go:196: connection=0 health=1318 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=1 health=1228 slowUploads=14 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=2 health=1194 slowUploads=14 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=3 health=1322 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=4 health=1331 slowUploads=14 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=5 health=1342 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=6 health=1321 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=7 health=1300 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:209: phase=afterReclaim elapsed=5.661s requests=10489 failures=0 heapBytes=440880 rssBytes=468951040 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:4 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestTLSMixedSoak (5.66s)
|
||||
=== RUN TestServerHandlesH2CRequest
|
||||
--- PASS: TestServerHandlesH2CRequest (0.00s)
|
||||
=== RUN TestServerEnforcesGlobalRequestLimitAndRecovers
|
||||
--- PASS: TestServerEnforcesGlobalRequestLimitAndRecovers (0.00s)
|
||||
=== RUN TestServerRejectsBodyAboveLimitAndRecovers
|
||||
--- PASS: TestServerRejectsBodyAboveLimitAndRecovers (0.00s)
|
||||
=== RUN TestServerRejectsHeadersAboveLimitAndRecovers
|
||||
2026/09/23 02:51:04 http2: server connection error from 127.0.0.1:36276: connection error: COMPRESSION_ERROR
|
||||
--- PASS: TestServerRejectsHeadersAboveLimitAndRecovers (1.00s)
|
||||
=== RUN TestServerMultiplexes128Streams
|
||||
--- PASS: TestServerMultiplexes128Streams (0.02s)
|
||||
=== RUN TestServerStreamsLargeBodyWithFlowControl
|
||||
--- PASS: TestServerStreamsLargeBodyWithFlowControl (0.16s)
|
||||
=== RUN TestServerTLSUsesH2ALPN
|
||||
--- PASS: TestServerTLSUsesH2ALPN (0.14s)
|
||||
=== RUN TestTLSSoak
|
||||
soak_test.go:26: phase=before elapsed=0s requests=0 failures=0 heapBytes=1497944 rssBytes=391569408 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:0 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
soak_test.go:159: phase=after elapsed=3.122s requests=18182 failures=0 heapBytes=4739552 rssBytes=238292992 goroutines=3 fileDescriptors=6 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestTLSSoak (3.12s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.478s
|
||||
3681
performance/http2MixedSoakLinux.json
Normal file
3681
performance/http2MixedSoakLinux.json
Normal file
File diff suppressed because it is too large
Load Diff
265
performance/http2MixedSoakLinux.txt
Normal file
265
performance/http2MixedSoakLinux.txt
Normal file
@@ -0,0 +1,265 @@
|
||||
HTTP/2 Linux soak image: golang:1.25
|
||||
HTTP/2 TLS soak duration: 30m
|
||||
HTTP/2 TLS soak mode: regular
|
||||
HTTP/2 TLS soak scenario: mixed
|
||||
HTTP/2 soak container: go-omni-http2-soak-fa422c57dfa1; limits: 2 CPUs, 2048 MiB, 256 tasks
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestTLSMixedSoak
|
||||
mixedSoak_test.go:37: phase=before elapsed=0s requests=0 failures=0 heapBytes=170136 rssBytes=8613888 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:0 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
mixedSoak_test.go:38: scenario=mixed connections=8 workersPerConnection=16 uploadBytes=262144 uploadChunkBytes=4096 uploadDelay=2ms downloadBytes=8388608 downloadChunkBytes=65536 downloadDelay=20ms
|
||||
mixedSoak_test.go:135: phase=running elapsed=15.143s requests=444111 failures=0 heapBytes=41226328 rssBytes=100597760 goroutines=359 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=443187 slowUploads=884 slowDownloads=40 uploadedBytes=231735296 downloadedBytes=335544320 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=30.143s requests=886889 failures=0 heapBytes=76939344 rssBytes=119885824 goroutines=351 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=885025 slowUploads=1776 slowDownloads=88 uploadedBytes=465567744 downloadedBytes=738197504 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=45.143s requests=1327236 failures=0 heapBytes=98442464 rssBytes=119926784 goroutines=348 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=1324437 slowUploads=2663 slowDownloads=136 uploadedBytes=698089472 downloadedBytes=1140850688 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=1m0.143s requests=1768857 failures=0 heapBytes=99328992 rssBytes=119402496 goroutines=359 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=1765116 slowUploads=3557 slowDownloads=184 uploadedBytes=932446208 downloadedBytes=1543503872 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=1m15.143s requests=2211456 failures=0 heapBytes=60732216 rssBytes=90337280 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=2206781 slowUploads=4445 slowDownloads=232 uploadedBytes=1165230080 downloadedBytes=1946157056 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=1m30.143s requests=2652108 failures=0 heapBytes=53063064 rssBytes=85688320 goroutines=350 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=2646501 slowUploads=5335 slowDownloads=272 uploadedBytes=1398538240 downloadedBytes=2281701376 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=1m45.143s requests=3094082 failures=0 heapBytes=81458456 rssBytes=117563392 goroutines=343 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=3087534 slowUploads=6228 slowDownloads=320 uploadedBytes=1632632832 downloadedBytes=2684354560 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=2m0.143s requests=3532884 failures=0 heapBytes=86878776 rssBytes=119468032 goroutines=292 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=3525398 slowUploads=7118 slowDownloads=368 uploadedBytes=1865940992 downloadedBytes=3087007744 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=2m15.143s requests=3974010 failures=0 heapBytes=98205016 rssBytes=119029760 goroutines=313 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=3965593 slowUploads=8008 slowDownloads=416 uploadedBytes=2099249152 downloadedBytes=3489660928 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=2m30.143s requests=4415820 failures=0 heapBytes=90842632 rssBytes=109375488 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=4406456 slowUploads=8900 slowDownloads=464 uploadedBytes=2333081600 downloadedBytes=3892314112 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=2m45.143s requests=4859795 failures=0 heapBytes=46212528 rssBytes=75120640 goroutines=382 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=4849501 slowUploads=9790 slowDownloads=504 uploadedBytes=2566389760 downloadedBytes=4227858432 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=3m0.143s requests=5302557 failures=0 heapBytes=59378560 rssBytes=103960576 goroutines=323 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=5291324 slowUploads=10681 slowDownloads=552 uploadedBytes=2799960064 downloadedBytes=4630511616 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=3m15.143s requests=5745355 failures=0 heapBytes=65581312 rssBytes=120012800 goroutines=326 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=5733185 slowUploads=11571 slowDownloads=600 uploadedBytes=3033268224 downloadedBytes=5033164800 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=3m30.143s requests=6184725 failures=0 heapBytes=83765136 rssBytes=119152640 goroutines=365 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=6171616 slowUploads=12461 slowDownloads=648 uploadedBytes=3266576384 downloadedBytes=5435817984 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=3m45.143s requests=6627613 failures=0 heapBytes=83719272 rssBytes=119508992 goroutines=326 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=6613566 slowUploads=13352 slowDownloads=696 uploadedBytes=3500146688 downloadedBytes=5838471168 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=4m0.143s requests=7072007 failures=0 heapBytes=40007456 rssBytes=83099648 goroutines=366 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=7057021 slowUploads=14246 slowDownloads=740 uploadedBytes=3734503424 downloadedBytes=6207569920 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=4m15.143s requests=7515397 failures=0 heapBytes=52621088 rssBytes=88940544 goroutines=320 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=7499478 slowUploads=15136 slowDownloads=784 uploadedBytes=3967811584 downloadedBytes=6576668672 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=4m30.143s requests=7957026 failures=0 heapBytes=49206800 rssBytes=115929088 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=7940168 slowUploads=16026 slowDownloads=832 uploadedBytes=4201119744 downloadedBytes=6979321856 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=4m45.143s requests=8395015 failures=0 heapBytes=57830768 rssBytes=120049664 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:13}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=8377223 slowUploads=16918 slowDownloads=880 uploadedBytes=4434952192 downloadedBytes=7381975040 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=5m0.143s requests=8838797 failures=0 heapBytes=66531296 rssBytes=120057856 goroutines=364 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=8820061 slowUploads=17808 slowDownloads=928 uploadedBytes=4668260352 downloadedBytes=7784628224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=5m15.143s requests=9279778 failures=0 heapBytes=60693440 rssBytes=103329792 goroutines=361 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=9260102 slowUploads=18700 slowDownloads=976 uploadedBytes=4902092800 downloadedBytes=8187281408 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=5m30.143s requests=9726070 failures=0 heapBytes=29946072 rssBytes=75563008 goroutines=288 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=9705463 slowUploads=19591 slowDownloads=1016 uploadedBytes=5135663104 downloadedBytes=8522825728 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=5m45.144s requests=10170774 failures=0 heapBytes=41152368 rssBytes=106692608 goroutines=360 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=10149227 slowUploads=20483 slowDownloads=1064 uploadedBytes=5369495552 downloadedBytes=8925478912 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=6m0.143s requests=10614632 failures=0 heapBytes=80360752 rssBytes=120602624 goroutines=325 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=10592151 slowUploads=21373 slowDownloads=1112 uploadedBytes=5602803712 downloadedBytes=9328132096 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=6m15.143s requests=11059452 failures=0 heapBytes=87305144 rssBytes=120602624 goroutines=324 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=11036026 slowUploads=22266 slowDownloads=1160 uploadedBytes=5836898304 downloadedBytes=9730785280 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=6m30.143s requests=11499123 failures=0 heapBytes=95978912 rssBytes=120606720 goroutines=365 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=11474760 slowUploads=23155 slowDownloads=1208 uploadedBytes=6069944320 downloadedBytes=10133438464 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=6m45.143s requests=11944358 failures=0 heapBytes=56042736 rssBytes=75292672 goroutines=366 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=11919058 slowUploads=24048 slowDownloads=1252 uploadedBytes=6304038912 downloadedBytes=10502537216 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=7m0.143s requests=12386374 failures=0 heapBytes=40072440 rssBytes=94666752 goroutines=354 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=12360140 slowUploads=24938 slowDownloads=1296 uploadedBytes=6537347072 downloadedBytes=10871635968 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=7m15.143s requests=12830700 failures=0 heapBytes=62440144 rssBytes=120365056 goroutines=328 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=12803533 slowUploads=25829 slowDownloads=1344 uploadedBytes=6770917376 downloadedBytes=11274289152 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=7m30.143s requests=13273492 failures=0 heapBytes=67101096 rssBytes=121159680 goroutines=334 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:13}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=13245381 slowUploads=26719 slowDownloads=1392 uploadedBytes=7004225536 downloadedBytes=11676942336 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=7m45.143s requests=13715931 failures=0 heapBytes=78929632 rssBytes=120643584 goroutines=361 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=13686880 slowUploads=27611 slowDownloads=1440 uploadedBytes=7238057984 downloadedBytes=12079595520 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=8m0.143s requests=14158538 failures=0 heapBytes=83472520 rssBytes=102039552 goroutines=342 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=14128556 slowUploads=28500 slowDownloads=1488 uploadedBytes=7471104000 downloadedBytes=12482248704 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=8m15.143s requests=14606106 failures=0 heapBytes=36808416 rssBytes=77897728 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=14575184 slowUploads=29394 slowDownloads=1528 uploadedBytes=7705460736 downloadedBytes=12817793024 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=8m30.143s requests=15050580 failures=0 heapBytes=51099992 rssBytes=106217472 goroutines=315 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=15018720 slowUploads=30284 slowDownloads=1576 uploadedBytes=7938768896 downloadedBytes=13220446208 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=8m45.143s requests=15492752 failures=0 heapBytes=99120736 rssBytes=121679872 goroutines=326 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=15459953 slowUploads=31175 slowDownloads=1624 uploadedBytes=8172339200 downloadedBytes=13623099392 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=9m0.143s requests=15937860 failures=0 heapBytes=95959552 rssBytes=121716736 goroutines=344 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=15904122 slowUploads=32066 slowDownloads=1672 uploadedBytes=8405909504 downloadedBytes=14025752576 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=9m15.143s requests=16379592 failures=0 heapBytes=54420112 rssBytes=121249792 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=16344914 slowUploads=32958 slowDownloads=1720 uploadedBytes=8639741952 downloadedBytes=14428405760 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=9m30.143s requests=16825611 failures=0 heapBytes=62855872 rssBytes=81920000 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=16790004 slowUploads=33848 slowDownloads=1764 uploadedBytes=8873050112 downloadedBytes=14797504512 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=9m45.144s requests=17273037 failures=0 heapBytes=56223560 rssBytes=98967552 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=17236495 slowUploads=34739 slowDownloads=1808 uploadedBytes=9106620416 downloadedBytes=15166603264 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=10m0.143s requests=17718289 failures=0 heapBytes=90363368 rssBytes=121196544 goroutines=312 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=17680804 slowUploads=35632 slowDownloads=1856 uploadedBytes=9340715008 downloadedBytes=15569256448 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=10m15.143s requests=18164393 failures=0 heapBytes=91620912 rssBytes=121708544 goroutines=328 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:14}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=18125968 slowUploads=36521 slowDownloads=1904 uploadedBytes=9573761024 downloadedBytes=15971909632 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=10m30.143s requests=18604920 failures=0 heapBytes=85189216 rssBytes=119672832 goroutines=397 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=18565557 slowUploads=37412 slowDownloads=1952 uploadedBytes=9807331328 downloadedBytes=16374562816 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=10m45.143s requests=19049168 failures=0 heapBytes=71446040 rssBytes=97730560 goroutines=371 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=19008872 slowUploads=38304 slowDownloads=2000 uploadedBytes=10041163776 downloadedBytes=16777216000 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=11m0.143s requests=19496908 failures=0 heapBytes=35314648 rssBytes=81412096 goroutines=333 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:6}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=19455674 slowUploads=39195 slowDownloads=2040 uploadedBytes=10274734080 downloadedBytes=17112760320 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=11m15.143s requests=19941947 failures=0 heapBytes=52063200 rssBytes=112910336 goroutines=353 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=19899771 slowUploads=40088 slowDownloads=2088 uploadedBytes=10508828672 downloadedBytes=17515413504 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=11m30.143s requests=20387835 failures=0 heapBytes=91179064 rssBytes=121262080 goroutines=337 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=20344721 slowUploads=40978 slowDownloads=2136 uploadedBytes=10742136832 downloadedBytes=17918066688 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=11m45.143s requests=20834104 failures=0 heapBytes=96727232 rssBytes=121204736 goroutines=373 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=20790049 slowUploads=41871 slowDownloads=2184 uploadedBytes=10976231424 downloadedBytes=18320719872 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=12m0.143s requests=21274032 failures=0 heapBytes=73363000 rssBytes=121729024 goroutines=395 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=21229039 slowUploads=42761 slowDownloads=2232 uploadedBytes=11209539584 downloadedBytes=18723373056 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=12m15.143s requests=21717175 failures=0 heapBytes=55163016 rssBytes=80625664 goroutines=345 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=21671245 slowUploads=43654 slowDownloads=2276 uploadedBytes=11443634176 downloadedBytes=19092471808 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=12m30.143s requests=22165567 failures=0 heapBytes=58932064 rssBytes=100704256 goroutines=332 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=22118701 slowUploads=44546 slowDownloads=2320 uploadedBytes=11677466624 downloadedBytes=19461570560 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=12m45.143s requests=22605128 failures=0 heapBytes=65821152 rssBytes=121774080 goroutines=295 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=22557331 slowUploads=45436 slowDownloads=2368 uploadedBytes=11910774784 downloadedBytes=19864223744 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=13m0.143s requests=23048491 failures=0 heapBytes=71758864 rssBytes=121741312 goroutines=346 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:14}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=22999748 slowUploads=46327 slowDownloads=2416 uploadedBytes=12144345088 downloadedBytes=20266876928 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=13m15.143s requests=23490751 failures=0 heapBytes=93734424 rssBytes=121221120 goroutines=360 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=23441069 slowUploads=47219 slowDownloads=2464 uploadedBytes=12378177536 downloadedBytes=20669530112 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=13m30.143s requests=23937415 failures=0 heapBytes=67599608 rssBytes=86278144 goroutines=361 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=23886795 slowUploads=48109 slowDownloads=2511 uploadedBytes=12611485696 downloadedBytes=21063794688 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=13m45.143s requests=24380375 failures=0 heapBytes=39698288 rssBytes=80867328 goroutines=299 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=24328828 slowUploads=49001 slowDownloads=2553 uploadedBytes=12845318144 downloadedBytes=21416116224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=14m0.143s requests=24821991 failures=0 heapBytes=56792672 rssBytes=110620672 goroutines=354 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=24769501 slowUploads=49890 slowDownloads=2600 uploadedBytes=13078364160 downloadedBytes=21810380800 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=14m15.143s requests=25267435 failures=0 heapBytes=59737600 rssBytes=121171968 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=25214005 slowUploads=50784 slowDownloads=2648 uploadedBytes=13312720896 downloadedBytes=22213033984 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=14m30.143s requests=25712957 failures=0 heapBytes=91392208 rssBytes=119664640 goroutines=341 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=25658586 slowUploads=51675 slowDownloads=2696 uploadedBytes=13546291200 downloadedBytes=22615687168 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=14m45.143s requests=26155555 failures=0 heapBytes=57260256 rssBytes=109522944 goroutines=359 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=26100248 slowUploads=52568 slowDownloads=2744 uploadedBytes=13780385792 downloadedBytes=23018340352 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=15m0.143s requests=26597733 failures=0 heapBytes=40190080 rssBytes=81768448 goroutines=359 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=26541487 slowUploads=53458 slowDownloads=2788 uploadedBytes=14013693952 downloadedBytes=23387439104 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=15m15.143s requests=27037735 failures=0 heapBytes=66516128 rssBytes=100077568 goroutines=362 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=26980556 slowUploads=54350 slowDownloads=2832 uploadedBytes=14247526400 downloadedBytes=23756537856 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=15m30.143s requests=27476076 failures=0 heapBytes=60893368 rssBytes=121782272 goroutines=333 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=27417961 slowUploads=55241 slowDownloads=2880 uploadedBytes=14481096704 downloadedBytes=24159191040 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=15m45.143s requests=27919500 failures=0 heapBytes=88112872 rssBytes=121765888 goroutines=349 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:14}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=27860441 slowUploads=56131 slowDownloads=2928 uploadedBytes=14714404864 downloadedBytes=24561844224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=16m0.143s requests=28363283 failures=0 heapBytes=76293576 rssBytes=121774080 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=28303284 slowUploads=57023 slowDownloads=2976 uploadedBytes=14948237312 downloadedBytes=24964497408 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=16m15.143s requests=28803143 failures=0 heapBytes=50553176 rssBytes=94076928 goroutines=335 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=28742207 slowUploads=57913 slowDownloads=3023 uploadedBytes=15181545472 downloadedBytes=25358761984 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=16m30.143s requests=29248721 failures=0 heapBytes=50037296 rssBytes=88997888 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=29186854 slowUploads=58806 slowDownloads=3064 uploadedBytes=15415640064 downloadedBytes=25702694912 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=16m45.143s requests=29691598 failures=0 heapBytes=56490576 rssBytes=112680960 goroutines=339 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=29628794 slowUploads=59696 slowDownloads=3112 uploadedBytes=15648948224 downloadedBytes=26105348096 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=17m0.143s requests=30135271 failures=0 heapBytes=101057680 rssBytes=121397248 goroutines=350 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=30071522 slowUploads=60589 slowDownloads=3160 uploadedBytes=15883042816 downloadedBytes=26508001280 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=17m15.143s requests=30578566 failures=0 heapBytes=102154656 rssBytes=121921536 goroutines=361 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:27}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=30513878 slowUploads=61480 slowDownloads=3208 uploadedBytes=16116613120 downloadedBytes=26910654464 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=17m30.143s requests=31020857 failures=0 heapBytes=86815776 rssBytes=105476096 goroutines=323 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=30955229 slowUploads=62372 slowDownloads=3256 uploadedBytes=16350445568 downloadedBytes=27313307648 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=17m45.143s requests=31459944 failures=0 heapBytes=51699872 rssBytes=79634432 goroutines=310 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=31393379 slowUploads=63265 slowDownloads=3300 uploadedBytes=16584540160 downloadedBytes=27682406400 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=18m0.143s requests=31903673 failures=0 heapBytes=77125768 rssBytes=103821312 goroutines=368 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=31836174 slowUploads=64155 slowDownloads=3344 uploadedBytes=16817848320 downloadedBytes=28051505152 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=18m15.143s requests=32347088 failures=0 heapBytes=87810928 rssBytes=121810944 goroutines=354 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=32278650 slowUploads=65047 slowDownloads=3392 uploadedBytes=17051680768 downloadedBytes=28454158336 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=18m30.143s requests=32791958 failures=0 heapBytes=83378912 rssBytes=121843712 goroutines=368 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=32722582 slowUploads=65940 slowDownloads=3440 uploadedBytes=17285775360 downloadedBytes=28856811520 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=18m45.143s requests=33238456 failures=0 heapBytes=73365344 rssBytes=121778176 goroutines=310 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=33168137 slowUploads=66831 slowDownloads=3488 uploadedBytes=17519345664 downloadedBytes=29259464704 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=19m0.143s requests=33678524 failures=0 heapBytes=73470384 rssBytes=96964608 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:13}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=33607268 slowUploads=67723 slowDownloads=3533 uploadedBytes=17753178112 downloadedBytes=29636952064 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=19m15.143s requests=34124154 failures=0 heapBytes=44371688 rssBytes=86138880 goroutines=306 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=34051968 slowUploads=68615 slowDownloads=3576 uploadedBytes=17987010560 downloadedBytes=29997662208 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=19m30.143s requests=34567776 failures=0 heapBytes=69339968 rssBytes=116219904 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=34494646 slowUploads=69506 slowDownloads=3624 uploadedBytes=18220580864 downloadedBytes=30400315392 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=19m45.143s requests=35012899 failures=0 heapBytes=77088312 rssBytes=122314752 goroutines=333 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=34938829 slowUploads=70399 slowDownloads=3672 uploadedBytes=18454675456 downloadedBytes=30802968576 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=20m0.143s requests=35459295 failures=0 heapBytes=71491968 rssBytes=121794560 goroutines=346 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=35384287 slowUploads=71292 slowDownloads=3720 uploadedBytes=18688770048 downloadedBytes=31205621760 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=20m15.143s requests=35904737 failures=0 heapBytes=71675952 rssBytes=106598400 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=35828787 slowUploads=72182 slowDownloads=3768 uploadedBytes=18922078208 downloadedBytes=31608274944 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=20m30.143s requests=36350214 failures=0 heapBytes=50574208 rssBytes=84537344 goroutines=306 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:10}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=36273329 slowUploads=73074 slowDownloads=3811 uploadedBytes=19155910656 downloadedBytes=31968985088 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=20m45.143s requests=36794194 failures=0 heapBytes=52755624 rssBytes=104304640 goroutines=347 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=36716374 slowUploads=73964 slowDownloads=3856 uploadedBytes=19389218816 downloadedBytes=32346472448 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=21m0.143s requests=37237267 failures=0 heapBytes=90759440 rssBytes=121802752 goroutines=285 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=37158506 slowUploads=74857 slowDownloads=3904 uploadedBytes=19623313408 downloadedBytes=32749125632 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=21m15.143s requests=37682135 failures=0 heapBytes=75071160 rssBytes=121802752 goroutines=332 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=37602436 slowUploads=75747 slowDownloads=3952 uploadedBytes=19856621568 downloadedBytes=33151778816 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=21m30.143s requests=38125279 failures=0 heapBytes=84651016 rssBytes=117342208 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=38044640 slowUploads=76639 slowDownloads=4000 uploadedBytes=20090454016 downloadedBytes=33554432000 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=21m45.143s requests=38569220 failures=0 heapBytes=70593904 rssBytes=97050624 goroutines=373 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=38487645 slowUploads=77531 slowDownloads=4044 uploadedBytes=20324286464 downloadedBytes=33923530752 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=22m0.143s requests=39016925 failures=0 heapBytes=59654016 rssBytes=93556736 goroutines=325 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=38934413 slowUploads=78424 slowDownloads=4088 uploadedBytes=20558381056 downloadedBytes=34292629504 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=22m15.143s requests=39463910 failures=0 heapBytes=53261856 rssBytes=114139136 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=39380459 slowUploads=79315 slowDownloads=4136 uploadedBytes=20791951360 downloadedBytes=34695282688 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=22m30.143s requests=39910237 failures=0 heapBytes=62860672 rssBytes=121815040 goroutines=366 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=39825849 slowUploads=80209 slowDownloads=4184 uploadedBytes=21026308096 downloadedBytes=35097935872 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=22m45.143s requests=40360417 failures=0 heapBytes=70322976 rssBytes=121286656 goroutines=368 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=40275086 slowUploads=81099 slowDownloads=4232 uploadedBytes=21259616256 downloadedBytes=35500589056 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=23m0.143s requests=40801788 failures=0 heapBytes=76912600 rssBytes=97243136 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=40715518 slowUploads=81990 slowDownloads=4280 uploadedBytes=21493186560 downloadedBytes=35903242240 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=23m15.143s requests=41246645 failures=0 heapBytes=54950248 rssBytes=84979712 goroutines=337 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=41159438 slowUploads=82883 slowDownloads=4324 uploadedBytes=21727281152 downloadedBytes=36272340992 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=23m30.143s requests=41687236 failures=0 heapBytes=43818576 rssBytes=101183488 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=41599097 slowUploads=83774 slowDownloads=4368 uploadedBytes=21960851456 downloadedBytes=36641439744 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=23m45.143s requests=42128546 failures=0 heapBytes=54495128 rssBytes=121827328 goroutines=288 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=42039470 slowUploads=84667 slowDownloads=4416 uploadedBytes=22194946048 downloadedBytes=37044092928 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=24m0.143s requests=42569443 failures=0 heapBytes=98238024 rssBytes=119263232 goroutines=362 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=42479426 slowUploads=85556 slowDownloads=4464 uploadedBytes=22427992064 downloadedBytes=37446746112 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=24m15.143s requests=43006434 failures=0 heapBytes=61451176 rssBytes=115818496 goroutines=330 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=42915474 slowUploads=86448 slowDownloads=4512 uploadedBytes=22661824512 downloadedBytes=37849399296 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=24m30.143s requests=43446211 failures=0 heapBytes=53221152 rssBytes=96935936 goroutines=294 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=43354315 slowUploads=87339 slowDownloads=4557 uploadedBytes=22895394816 downloadedBytes=38226886656 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=24m45.143s requests=43888201 failures=0 heapBytes=41094952 rssBytes=88412160 goroutines=306 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=43795377 slowUploads=88230 slowDownloads=4600 uploadedBytes=23128965120 downloadedBytes=38587596800 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=25m0.143s requests=44331683 failures=0 heapBytes=74520696 rssBytes=117968896 goroutines=360 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=44237912 slowUploads=89123 slowDownloads=4648 uploadedBytes=23363059712 downloadedBytes=38990249984 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=25m15.143s requests=44771023 failures=0 heapBytes=61522184 rssBytes=121311232 goroutines=314 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=44676311 slowUploads=90016 slowDownloads=4696 uploadedBytes=23597154304 downloadedBytes=39392903168 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=25m30.143s requests=45210979 failures=0 heapBytes=71823520 rssBytes=121315328 goroutines=364 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=45115328 slowUploads=90908 slowDownloads=4744 uploadedBytes=23830986752 downloadedBytes=39795556352 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=25m45.143s requests=45655488 failures=0 heapBytes=73294312 rssBytes=98951168 goroutines=352 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=45558899 slowUploads=91798 slowDownloads=4791 uploadedBytes=24064294912 downloadedBytes=40189820928 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=26m0.143s requests=46098111 failures=0 heapBytes=61891384 rssBytes=85794816 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=46000586 slowUploads=92690 slowDownloads=4835 uploadedBytes=24298127360 downloadedBytes=40558919680 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=26m15.143s requests=46542135 failures=0 heapBytes=60185208 rssBytes=103088128 goroutines=303 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=46443672 slowUploads=93583 slowDownloads=4880 uploadedBytes=24532221952 downloadedBytes=40936407040 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=26m30.143s requests=46985865 failures=0 heapBytes=82859896 rssBytes=121839616 goroutines=364 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=46886464 slowUploads=94473 slowDownloads=4928 uploadedBytes=24765530112 downloadedBytes=41339060224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=26m45.143s requests=47426140 failures=0 heapBytes=76512768 rssBytes=121827328 goroutines=319 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=47325797 slowUploads=95367 slowDownloads=4976 uploadedBytes=24999886848 downloadedBytes=41741713408 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=27m0.143s requests=47864835 failures=0 heapBytes=96303144 rssBytes=115740672 goroutines=380 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=47763553 slowUploads=96258 slowDownloads=5024 uploadedBytes=25233457152 downloadedBytes=42144366592 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=27m15.143s requests=48307600 failures=0 heapBytes=74108016 rssBytes=93282304 goroutines=358 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=48205383 slowUploads=97149 slowDownloads=5068 uploadedBytes=25467027456 downloadedBytes=42513465344 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=27m30.143s requests=48750920 failures=0 heapBytes=48575472 rssBytes=90746880 goroutines=376 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=48647765 slowUploads=98043 slowDownloads=5112 uploadedBytes=25701384192 downloadedBytes=42882564096 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=27m45.143s requests=49191794 failures=0 heapBytes=89907488 rssBytes=120475648 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=49087700 slowUploads=98934 slowDownloads=5160 uploadedBytes=25934954496 downloadedBytes=43285217280 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=28m0.143s requests=49637025 failures=0 heapBytes=71241912 rssBytes=120827904 goroutines=338 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=49531996 slowUploads=99826 slowDownloads=5208 uploadedBytes=26168786944 downloadedBytes=43687870464 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=28m15.143s requests=50077201 failures=0 heapBytes=71823760 rssBytes=121327616 goroutines=342 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=49971226 slowUploads=100719 slowDownloads=5256 uploadedBytes=26402881536 downloadedBytes=44090523648 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=28m30.143s requests=50524644 failures=0 heapBytes=88321680 rssBytes=107315200 goroutines=315 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=50417733 slowUploads=101610 slowDownloads=5303 uploadedBytes=26636451840 downloadedBytes=44484788224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=28m45.143s requests=50971603 failures=0 heapBytes=49721352 rssBytes=87646208 goroutines=335 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=50863761 slowUploads=102501 slowDownloads=5346 uploadedBytes=26870022144 downloadedBytes=44845498368 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=29m0.143s requests=51417802 failures=0 heapBytes=55859872 rssBytes=104423424 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=51309017 slowUploads=103394 slowDownloads=5392 uploadedBytes=27104116736 downloadedBytes=45231374336 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=29m15.143s requests=51862535 failures=0 heapBytes=63482984 rssBytes=121856000 goroutines=349 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:10}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=51752810 slowUploads=104285 slowDownloads=5440 uploadedBytes=27337687040 downloadedBytes=45634027520 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=29m30.143s requests=52305456 failures=0 heapBytes=85512792 rssBytes=118308864 goroutines=332 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=52194795 slowUploads=105177 slowDownloads=5488 uploadedBytes=27571519488 downloadedBytes=46036680704 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=29m45.143s requests=52747376 failures=0 heapBytes=74143584 rssBytes=114286592 goroutines=333 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=52635773 slowUploads=106069 slowDownloads=5536 uploadedBytes=27805351936 downloadedBytes=46439333888 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=30m0.143s requests=53191576 failures=0 heapBytes=43650296 rssBytes=97910784 goroutines=272 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=53079033 slowUploads=106963 slowDownloads=5580 uploadedBytes=28039708672 downloadedBytes=46808432640 serverFailures=0
|
||||
mixedSoak_test.go:190: phase=after elapsed=30m2.635s requests=53191687 failures=0 heapBytes=44586352 rssBytes=97927168 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:5 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
mixedSoak_test.go:124: mixedTotals phase=after health=53079128 slowUploads=106971 slowDownloads=5588 uploadedBytes=28041805824 downloadedBytes=46875541504 serverFailures=0
|
||||
mixedSoak_test.go:196: connection=0 health=6632549 slowUploads=13372 slowDownloads=698
|
||||
mixedSoak_test.go:196: connection=1 health=6629530 slowUploads=13371 slowDownloads=699
|
||||
mixedSoak_test.go:196: connection=2 health=6636544 slowUploads=13376 slowDownloads=699
|
||||
mixedSoak_test.go:196: connection=3 health=6634170 slowUploads=13369 slowDownloads=698
|
||||
mixedSoak_test.go:196: connection=4 health=6636236 slowUploads=13368 slowDownloads=698
|
||||
mixedSoak_test.go:196: connection=5 health=6640096 slowUploads=13371 slowDownloads=698
|
||||
mixedSoak_test.go:196: connection=6 health=6634043 slowUploads=13372 slowDownloads=699
|
||||
mixedSoak_test.go:196: connection=7 health=6635960 slowUploads=13372 slowDownloads=699
|
||||
mixedSoak_test.go:209: phase=afterReclaim elapsed=30m2.639s requests=53191687 failures=0 heapBytes=461176 rssBytes=95506432 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:5 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestTLSMixedSoak (1802.64s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 1802.651s
|
||||
PASS HTTP/2 TLS soak
|
||||
42
performance/http2ProxyCleanupLinux.json
Normal file
42
performance/http2ProxyCleanupLinux.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"testedCommit": "b54a303ccdc034cd3062e846c6c6b2c24e55ffaa",
|
||||
"runnerSha256": "9ef2774b1d19c05f1ae8d9527665dba541561a7c677f9984a24e72d5bddb6ad5",
|
||||
"startedAt": "2026-09-23T03:13:01.977797+00:00",
|
||||
"cases": [
|
||||
{
|
||||
"case": "wallTimeout",
|
||||
"status": "passed",
|
||||
"expectedFailureExitCode": 1,
|
||||
"runnerError": "Command '['/usr/bin/docker', 'run', '--rm', '--name', 'go-omni-http2-proxy-ba2aaeecdd00-build', '--cpus', '2', '--memory', '2048m', '--memory-swap', '2048m', '--pids-limit', '256', '--log-opt', 'max-size=10m', '--log-opt', 'max-file=1', '-v', '/root/go-omni-validation:/workspace:ro', '-v', '/tmp/goOmniHTTP2Proxyi0b0o9y6:/output', '-w', '/workspace', '-e', 'GOWORK=off', '-e', 'GOMAXPROCS=2', '-e', 'CGO_ENABLED=0', '-e', 'GOPROXY=https://goproxy.cn,direct', 'golang:1.25', 'sh', '-c', 'go version && go vet ./transport/http2 && go test -c -o /output/http2.test ./transport/http2']' timed out after 1.8251966070383787 seconds",
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-ba2aaeecdd00-build"
|
||||
],
|
||||
"preExistingContainersStillRunning": 7,
|
||||
"remainingValidationContainers": []
|
||||
},
|
||||
{
|
||||
"case": "sigterm",
|
||||
"status": "passed",
|
||||
"expectedFailureExitCode": 1,
|
||||
"runnerError": "KeyboardInterrupt",
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-6307142e05ee-build"
|
||||
],
|
||||
"preExistingContainersStillRunning": 7,
|
||||
"remainingValidationContainers": []
|
||||
},
|
||||
{
|
||||
"case": "sighup",
|
||||
"status": "passed",
|
||||
"expectedFailureExitCode": 1,
|
||||
"runnerError": "KeyboardInterrupt",
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-dcf3bd54fbe4-build"
|
||||
],
|
||||
"preExistingContainersStillRunning": 7,
|
||||
"remainingValidationContainers": []
|
||||
}
|
||||
],
|
||||
"status": "passed",
|
||||
"finishedAt": "2026-09-23T03:13:06.934079+00:00"
|
||||
}
|
||||
142
performance/http2ProxyLinux.json
Normal file
142
performance/http2ProxyLinux.json
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"startedAt": "2026-09-23T03:10:31.408631+00:00",
|
||||
"testedCommit": "b54a303ccdc034cd3062e846c6c6b2c24e55ffaa",
|
||||
"workingTreeStatus": "",
|
||||
"sourceSha256": {
|
||||
"go.mod": "28d68eb2c0a019cceea884e88ab2335f44ad930e1890e28871638f4b073b4f86",
|
||||
"go.sum": "d9c2a5d0de94f08bd18a14a9626f9dda559c245d89bf8a689e32acbd2136fb05",
|
||||
"scripts/verifyHTTP2Proxy.py": "9ef2774b1d19c05f1ae8d9527665dba541561a7c677f9984a24e72d5bddb6ad5",
|
||||
"transport/http2/admission_test.go": "342a7d02bba508100a207b77246ca92e0fa04dd5f8e4a27091a01866b6750d39",
|
||||
"transport/http2/flowControl_test.go": "595b2e937cef2142ff6b6386116e338d874d0fe49e63d80f6de2e1268378dba2",
|
||||
"transport/http2/frameObserver.go": "fcdf431f8269ad9cfd65dd14102ce5c42ac8c4b8b833ed42c4ae15f2a7c7f14c",
|
||||
"transport/http2/lifecycle_test.go": "370a16de8b20248bb78021ab338b9f62ec51f733f270c0fa92f48b1a3ccb9eae",
|
||||
"transport/http2/mixedSoak_test.go": "2111737fe0405bc809bf06325573f7356160ae7fcaebcce06dcd94b2d9e7dccd",
|
||||
"transport/http2/proxy_test.go": "808d0fa7e6c3b0237b1adf4a71a4f30ae2dbc7d1a9d53fab164cda33b15c9d69",
|
||||
"transport/http2/server.go": "aeecdc3aec42219862d70ec788bef5df332189895555bc5d390849a51bd0fbfb",
|
||||
"transport/http2/server_test.go": "c4613e77fa885b6362c06c393937c7dcf9a7ffb4a9a32f85e21fc2f741de81a6",
|
||||
"transport/http2/soak_test.go": "52baa37e37dba497c579b6b55d723bda866ac57676254dc878c511345acc5af2"
|
||||
},
|
||||
"repetitions": 3,
|
||||
"race": false,
|
||||
"containerLimits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memoryAndSwapBytes": 2147483648,
|
||||
"pidsLimit": 256
|
||||
},
|
||||
"wallTimeoutSeconds": 600,
|
||||
"imageIds": {
|
||||
"golang:1.25": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"nginx:1.30.5-alpine": "sha256:bf3201ab56f23e5954646379c775d511fc466e9f11376d9725361064ad07ed35"
|
||||
},
|
||||
"notes": [
|
||||
"Compiler and integration stages run sequentially; at most one 2-CPU/2-GiB validation container is active.",
|
||||
"The integration container has network=none, no published ports and a read-only root filesystem. Client, Nginx and origins communicate over loopback inside that container.",
|
||||
"Both TLS legs validate certificates; invalid upstream CA and hostname must fail before reaching the handler.",
|
||||
"HTTP/2 is verified independently on both legs. This is a functional integration test, not a throughput benchmark or a live Cloudflare deployment.",
|
||||
"Single-process Nginx fixture with generated test certificates. Rolling drain switches between old and backup origins; this does not test Nginx master/worker configuration reloads or live Cloudflare routing."
|
||||
],
|
||||
"proxyVersions": [
|
||||
"nginx version: nginx/1.30.5"
|
||||
],
|
||||
"results": [
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}",
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}",
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}"
|
||||
],
|
||||
"goVersion": "go1.25.14",
|
||||
"platform": "linux/amd64",
|
||||
"commands": [
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-proxy-52da1679e6df-build",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"-v",
|
||||
"/root/go-omni-validation:/workspace:ro",
|
||||
"-v",
|
||||
"/tmp/goOmniHTTP2Proxyk3cc3h1_:/output",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"CGO_ENABLED=0",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go vet ./transport/http2 && go test -c -o /output/http2.test ./transport/http2"
|
||||
],
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-proxy-52da1679e6df-check",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,noexec,size=128m",
|
||||
"-v",
|
||||
"/tmp/goOmniHTTP2Proxyk3cc3h1_:/test:ro",
|
||||
"--entrypoint",
|
||||
"/test/http2.test",
|
||||
"-e",
|
||||
"GO_OMNI_HTTP2_PROXY_TEST=1",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"nginx:1.30.5-alpine",
|
||||
"-test.v",
|
||||
"-test.run",
|
||||
"^TestNginxHTTP2Proxy$",
|
||||
"-test.timeout",
|
||||
"120s",
|
||||
"-test.count",
|
||||
"3"
|
||||
]
|
||||
],
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-52da1679e6df-build",
|
||||
"go-omni-http2-proxy-52da1679e6df-check"
|
||||
],
|
||||
"finishedAt": "2026-09-23T03:11:00.363119+00:00",
|
||||
"logSha256": "1263ceb8ce7b65f542898e17031ba56127672d363defd0ee6520199fa1927472",
|
||||
"preExistingContainersStillRunning": 7
|
||||
}
|
||||
40
performance/http2ProxyLinux.txt
Normal file
40
performance/http2ProxyLinux.txt
Normal file
@@ -0,0 +1,40 @@
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (1.03s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.39s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.42s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.22s)
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (0.81s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.17s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.45s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.20s)
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (0.89s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.21s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.44s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.23s)
|
||||
PASS
|
||||
147
performance/http2ProxyRaceLinux.json
Normal file
147
performance/http2ProxyRaceLinux.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"startedAt": "2026-09-23T03:11:00.470197+00:00",
|
||||
"testedCommit": "b54a303ccdc034cd3062e846c6c6b2c24e55ffaa",
|
||||
"workingTreeStatus": "",
|
||||
"sourceSha256": {
|
||||
"go.mod": "28d68eb2c0a019cceea884e88ab2335f44ad930e1890e28871638f4b073b4f86",
|
||||
"go.sum": "d9c2a5d0de94f08bd18a14a9626f9dda559c245d89bf8a689e32acbd2136fb05",
|
||||
"scripts/verifyHTTP2Proxy.py": "9ef2774b1d19c05f1ae8d9527665dba541561a7c677f9984a24e72d5bddb6ad5",
|
||||
"transport/http2/admission_test.go": "342a7d02bba508100a207b77246ca92e0fa04dd5f8e4a27091a01866b6750d39",
|
||||
"transport/http2/flowControl_test.go": "595b2e937cef2142ff6b6386116e338d874d0fe49e63d80f6de2e1268378dba2",
|
||||
"transport/http2/frameObserver.go": "fcdf431f8269ad9cfd65dd14102ce5c42ac8c4b8b833ed42c4ae15f2a7c7f14c",
|
||||
"transport/http2/lifecycle_test.go": "370a16de8b20248bb78021ab338b9f62ec51f733f270c0fa92f48b1a3ccb9eae",
|
||||
"transport/http2/mixedSoak_test.go": "2111737fe0405bc809bf06325573f7356160ae7fcaebcce06dcd94b2d9e7dccd",
|
||||
"transport/http2/proxy_test.go": "808d0fa7e6c3b0237b1adf4a71a4f30ae2dbc7d1a9d53fab164cda33b15c9d69",
|
||||
"transport/http2/server.go": "aeecdc3aec42219862d70ec788bef5df332189895555bc5d390849a51bd0fbfb",
|
||||
"transport/http2/server_test.go": "c4613e77fa885b6362c06c393937c7dcf9a7ffb4a9a32f85e21fc2f741de81a6",
|
||||
"transport/http2/soak_test.go": "52baa37e37dba497c579b6b55d723bda866ac57676254dc878c511345acc5af2"
|
||||
},
|
||||
"repetitions": 3,
|
||||
"race": true,
|
||||
"containerLimits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memoryAndSwapBytes": 2147483648,
|
||||
"pidsLimit": 256
|
||||
},
|
||||
"wallTimeoutSeconds": 600,
|
||||
"imageIds": {
|
||||
"golang:1.25": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"nginx:1.30.5": "sha256:0aa2d81d65bc0cac0407e738b8f07d312c8685a84225fcb4db7bcbdd8c9bdf11"
|
||||
},
|
||||
"notes": [
|
||||
"Compiler and integration stages run sequentially; at most one 2-CPU/2-GiB validation container is active.",
|
||||
"The integration container has network=none, no published ports and a read-only root filesystem. Client, Nginx and origins communicate over loopback inside that container.",
|
||||
"Both TLS legs validate certificates; invalid upstream CA and hostname must fail before reaching the handler.",
|
||||
"HTTP/2 is verified independently on both legs. This is a functional integration test, not a throughput benchmark or a live Cloudflare deployment.",
|
||||
"Single-process Nginx fixture with generated test certificates. Rolling drain switches between old and backup origins; this does not test Nginx master/worker configuration reloads or live Cloudflare routing."
|
||||
],
|
||||
"proxyVersions": [
|
||||
"nginx version: nginx/1.30.5"
|
||||
],
|
||||
"results": [
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}",
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}",
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}"
|
||||
],
|
||||
"goVersion": "go1.25.14",
|
||||
"platform": "linux/amd64",
|
||||
"commands": [
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"pull",
|
||||
"nginx:1.30.5"
|
||||
],
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-proxy-ca37da979ea6-build",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"-v",
|
||||
"/root/go-omni-validation:/workspace:ro",
|
||||
"-v",
|
||||
"/tmp/goOmniHTTP2Proxy8ll9pvvs:/output",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"CGO_ENABLED=1",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go vet ./transport/http2 && go test -race -c -o /output/http2.test ./transport/http2"
|
||||
],
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-proxy-ca37da979ea6-check",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,noexec,size=128m",
|
||||
"-v",
|
||||
"/tmp/goOmniHTTP2Proxy8ll9pvvs:/test:ro",
|
||||
"--entrypoint",
|
||||
"/test/http2.test",
|
||||
"-e",
|
||||
"GO_OMNI_HTTP2_PROXY_TEST=1",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"nginx:1.30.5",
|
||||
"-test.v",
|
||||
"-test.run",
|
||||
"^TestNginxHTTP2Proxy$",
|
||||
"-test.timeout",
|
||||
"120s",
|
||||
"-test.count",
|
||||
"3"
|
||||
]
|
||||
],
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-ca37da979ea6-build",
|
||||
"go-omni-http2-proxy-ca37da979ea6-check"
|
||||
],
|
||||
"finishedAt": "2026-09-23T03:12:51.684028+00:00",
|
||||
"logSha256": "aaae7678dd4f882c51c008eaeb78afd897ccf2f7ba31c926a40839de755bb595",
|
||||
"preExistingContainersStillRunning": 7
|
||||
}
|
||||
67
performance/http2ProxyRaceLinux.txt
Normal file
67
performance/http2ProxyRaceLinux.txt
Normal file
@@ -0,0 +1,67 @@
|
||||
1.30.5: Pulling from library/nginx
|
||||
757049f14b94: Pulling fs layer
|
||||
af529266d394: Pulling fs layer
|
||||
3a55ec62cf16: Pulling fs layer
|
||||
2aa35770407f: Pulling fs layer
|
||||
f3cb4f951034: Pulling fs layer
|
||||
6b37362b3da7: Pulling fs layer
|
||||
b9614c6dbd86: Pulling fs layer
|
||||
2aa35770407f: Download complete
|
||||
d6363fd2ac35: Download complete
|
||||
3a55ec62cf16: Download complete
|
||||
02ac47b2a7a6: Download complete
|
||||
af529266d394: Download complete
|
||||
757049f14b94: Download complete
|
||||
f3cb4f951034: Download complete
|
||||
6b37362b3da7: Download complete
|
||||
6b37362b3da7: Pull complete
|
||||
b9614c6dbd86: Download complete
|
||||
3a55ec62cf16: Pull complete
|
||||
f3cb4f951034: Pull complete
|
||||
b9614c6dbd86: Pull complete
|
||||
2aa35770407f: Pull complete
|
||||
af529266d394: Pull complete
|
||||
757049f14b94: Pull complete
|
||||
Digest: sha256:0aa2d81d65bc0cac0407e738b8f07d312c8685a84225fcb4db7bcbdd8c9bdf11
|
||||
Status: Downloaded newer image for nginx:1.30.5
|
||||
docker.io/library/nginx:1.30.5
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (0.92s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.31s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.41s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.19s)
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (1.14s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.28s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.56s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.30s)
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (0.92s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.31s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.44s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.16s)
|
||||
PASS
|
||||
84
performance/http2SmokeLinux.json
Normal file
84
performance/http2SmokeLinux.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"createdAt": "2026-09-22T17:22:44.674797+00:00",
|
||||
"platform": "linux/amd64",
|
||||
"image": "golang:1.25",
|
||||
"imageId": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"sourceSha256": {
|
||||
"scripts/verifyHTTP2.py": "2c9f4ca095e16e9b30b80b5d812b2644577222274e5a8041e0fb178e305197a4",
|
||||
"transport/http2/soak_test.go": "9964a1670c8ffe30b27e96375e0ae72d00996b59beede748756013e8fd52eacf"
|
||||
},
|
||||
"containerLimits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memoryAndSwapBytes": 2147483648,
|
||||
"pids": 256
|
||||
},
|
||||
"gomaxprocs": 2,
|
||||
"workers": 128,
|
||||
"notes": [
|
||||
"Bounded smoke validation, not completion of the 30-minute HTTP/2 acceptance gate.",
|
||||
"TLS HTTP/2 over container loopback, with the client and server in one process; memory is not server-only.",
|
||||
"One TCP connection multiplexes the 128 workers; each response checks HTTP/2, status, body and a unique request ID.",
|
||||
"MiB uses 1024*1024 bytes. RSS is /proc/self/statm; heap is runtime.ReadMemStats. Samples every 15 seconds.",
|
||||
"The after sample includes the one monitoring goroutine; it is stopped and joined before the test returns.",
|
||||
"All seven pre-existing containers remained running, and no soak containers remained after cleanup.",
|
||||
"The prior unbounded 30-minute race run was stopped after RSS growth; its result is not counted as a pass.",
|
||||
"The short A/B comparison does not establish the complete cause of the old server incident."
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"mode": "regular",
|
||||
"duration": "2m",
|
||||
"elapsedSeconds": 120.04,
|
||||
"requests": 3885273,
|
||||
"failures": 0,
|
||||
"protocolErrors": 0,
|
||||
"sampledPeakRssBytes": 22437888,
|
||||
"before": {
|
||||
"heapBytes": 168040,
|
||||
"rssBytes": 8491008,
|
||||
"goroutines": 2,
|
||||
"fileDescriptors": 6
|
||||
},
|
||||
"after": {
|
||||
"heapBytes": 3925848,
|
||||
"rssBytes": 21364736,
|
||||
"goroutines": 3,
|
||||
"fileDescriptors": 6,
|
||||
"activeConnections": 0,
|
||||
"activeRequests": 0
|
||||
},
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"mode": "race",
|
||||
"duration": "60s",
|
||||
"elapsedSeconds": 60.127,
|
||||
"requests": 377788,
|
||||
"failures": 0,
|
||||
"protocolErrors": 0,
|
||||
"sampledPeakRssBytes": 492707840,
|
||||
"before": {
|
||||
"heapBytes": 169256,
|
||||
"rssBytes": 23732224,
|
||||
"goroutines": 2,
|
||||
"fileDescriptors": 6
|
||||
},
|
||||
"after": {
|
||||
"heapBytes": 2722632,
|
||||
"rssBytes": 487239680,
|
||||
"goroutines": 3,
|
||||
"fileDescriptors": 6,
|
||||
"activeConnections": 0,
|
||||
"activeRequests": 0
|
||||
},
|
||||
"exitCode": 0
|
||||
}
|
||||
],
|
||||
"cleanupValidation": {
|
||||
"sigterm": "passed",
|
||||
"sighup": "passed",
|
||||
"wallTimeout": "passed"
|
||||
},
|
||||
"thirtyMinuteAcceptance": "pending"
|
||||
}
|
||||
2624
performance/http2SoakLinux.json
Normal file
2624
performance/http2SoakLinux.json
Normal file
File diff suppressed because it is too large
Load Diff
133
performance/http2SoakLinux.txt
Normal file
133
performance/http2SoakLinux.txt
Normal file
@@ -0,0 +1,133 @@
|
||||
HTTP/2 Linux soak image: golang:1.25
|
||||
HTTP/2 TLS soak duration: 30m
|
||||
HTTP/2 TLS soak mode: regular
|
||||
HTTP/2 soak container: go-omni-http2-soak-b8d45477120a; limits: 2 CPUs, 2048 MiB, 256 tasks
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestTLSSoak
|
||||
soak_test.go:33: phase=before elapsed=0s requests=0 failures=0 heapBytes=168680 rssBytes=8441856 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:0 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=15.06s requests=483494 failures=0 heapBytes=6913536 rssBytes=22216704 goroutines=240 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=30.06s requests=961350 failures=0 heapBytes=6818840 rssBytes=22302720 goroutines=327 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=45.06s requests=1434755 failures=0 heapBytes=5234048 rssBytes=21180416 goroutines=267 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=1m0.06s requests=1917020 failures=0 heapBytes=3959208 rssBytes=21983232 goroutines=256 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=1m15.06s requests=2405587 failures=0 heapBytes=6696432 rssBytes=22294528 goroutines=355 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=1m30.06s requests=2893800 failures=0 heapBytes=5880824 rssBytes=21860352 goroutines=335 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=1m45.06s requests=3381213 failures=0 heapBytes=4176008 rssBytes=22257664 goroutines=331 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=2m0.06s requests=3867826 failures=0 heapBytes=6923296 rssBytes=22417408 goroutines=299 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=2m15.06s requests=4355136 failures=0 heapBytes=4863216 rssBytes=22257664 goroutines=331 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=2m30.06s requests=4844005 failures=0 heapBytes=4875168 rssBytes=22208512 goroutines=352 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=2m45.06s requests=5331034 failures=0 heapBytes=4053832 rssBytes=22085632 goroutines=354 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=3m0.061s requests=5824954 failures=0 heapBytes=7276088 rssBytes=22253568 goroutines=276 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=3m15.06s requests=6316211 failures=0 heapBytes=6082672 rssBytes=22413312 goroutines=340 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=3m30.06s requests=6795314 failures=0 heapBytes=6157488 rssBytes=22106112 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=3m45.06s requests=7279828 failures=0 heapBytes=6538536 rssBytes=22454272 goroutines=296 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=4m0.06s requests=7763620 failures=0 heapBytes=5209696 rssBytes=22003712 goroutines=371 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=4m15.06s requests=8245520 failures=0 heapBytes=4119296 rssBytes=22532096 goroutines=325 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=4m30.06s requests=8728841 failures=0 heapBytes=6352704 rssBytes=22863872 goroutines=336 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=4m45.06s requests=9206490 failures=0 heapBytes=5696104 rssBytes=21614592 goroutines=227 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=5m0.06s requests=9692044 failures=0 heapBytes=6982152 rssBytes=22798336 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=5m15.06s requests=10181610 failures=0 heapBytes=4842816 rssBytes=23273472 goroutines=233 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=5m30.06s requests=10674122 failures=0 heapBytes=4987832 rssBytes=21663744 goroutines=390 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=5m45.06s requests=11158495 failures=0 heapBytes=4861520 rssBytes=21594112 goroutines=255 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=6m0.06s requests=11643125 failures=0 heapBytes=6995288 rssBytes=22351872 goroutines=370 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=6m15.06s requests=12134189 failures=0 heapBytes=5791656 rssBytes=22269952 goroutines=322 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=6m30.06s requests=12617974 failures=0 heapBytes=4615832 rssBytes=22204416 goroutines=293 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=6m45.06s requests=13109107 failures=0 heapBytes=6241656 rssBytes=22806528 goroutines=331 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=7m0.06s requests=13597755 failures=0 heapBytes=4717608 rssBytes=22777856 goroutines=355 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=7m15.06s requests=14082999 failures=0 heapBytes=7078376 rssBytes=23724032 goroutines=352 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=7m30.06s requests=14573956 failures=0 heapBytes=4402216 rssBytes=21753856 goroutines=364 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=7m45.06s requests=15062222 failures=0 heapBytes=5805904 rssBytes=22765568 goroutines=366 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=8m0.06s requests=15550159 failures=0 heapBytes=6706008 rssBytes=22376448 goroutines=332 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=8m15.06s requests=16032745 failures=0 heapBytes=5471120 rssBytes=22126592 goroutines=318 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=8m30.061s requests=16516193 failures=0 heapBytes=7097144 rssBytes=22880256 goroutines=365 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=8m45.061s requests=16995862 failures=0 heapBytes=5984144 rssBytes=22609920 goroutines=288 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=9m0.06s requests=17480917 failures=0 heapBytes=5254728 rssBytes=22745088 goroutines=284 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=9m15.06s requests=17969665 failures=0 heapBytes=5609544 rssBytes=22839296 goroutines=264 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=9m30.06s requests=18456021 failures=0 heapBytes=4073608 rssBytes=22032384 goroutines=291 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=9m45.06s requests=18943865 failures=0 heapBytes=5703584 rssBytes=22515712 goroutines=250 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=10m0.06s requests=19427994 failures=0 heapBytes=4484904 rssBytes=23121920 goroutines=286 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=10m15.06s requests=19911036 failures=0 heapBytes=5654336 rssBytes=22589440 goroutines=330 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=10m30.06s requests=20398330 failures=0 heapBytes=7287736 rssBytes=22564864 goroutines=262 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=10m45.06s requests=20885304 failures=0 heapBytes=4866728 rssBytes=22773760 goroutines=340 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=11m0.06s requests=21377882 failures=0 heapBytes=4500144 rssBytes=22474752 goroutines=197 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=11m15.06s requests=21859381 failures=0 heapBytes=4837744 rssBytes=22278144 goroutines=346 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=11m30.06s requests=22340499 failures=0 heapBytes=4255808 rssBytes=22675456 goroutines=265 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=11m45.06s requests=22831603 failures=0 heapBytes=7122880 rssBytes=22138880 goroutines=322 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=12m0.06s requests=23322670 failures=0 heapBytes=4139848 rssBytes=22515712 goroutines=325 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=12m15.06s requests=23810489 failures=0 heapBytes=6155784 rssBytes=22671360 goroutines=281 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=12m30.06s requests=24298600 failures=0 heapBytes=6864792 rssBytes=22568960 goroutines=196 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=12m45.06s requests=24784321 failures=0 heapBytes=3708712 rssBytes=22278144 goroutines=295 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=13m0.06s requests=25270364 failures=0 heapBytes=5901032 rssBytes=22634496 goroutines=337 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=13m15.06s requests=25748374 failures=0 heapBytes=5453384 rssBytes=22368256 goroutines=313 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=13m30.06s requests=26231067 failures=0 heapBytes=7024176 rssBytes=22790144 goroutines=259 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=13m45.06s requests=26712235 failures=0 heapBytes=6159920 rssBytes=23277568 goroutines=332 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=14m0.06s requests=27192429 failures=0 heapBytes=6571248 rssBytes=22429696 goroutines=281 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=14m15.06s requests=27674241 failures=0 heapBytes=5498824 rssBytes=22700032 goroutines=307 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=14m30.06s requests=28161405 failures=0 heapBytes=7281776 rssBytes=22188032 goroutines=365 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=14m45.06s requests=28643479 failures=0 heapBytes=5348280 rssBytes=22425600 goroutines=257 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=15m0.06s requests=29126808 failures=0 heapBytes=7028080 rssBytes=23273472 goroutines=264 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=15m15.06s requests=29606183 failures=0 heapBytes=5075368 rssBytes=21872640 goroutines=363 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=15m30.06s requests=30083605 failures=0 heapBytes=5377456 rssBytes=22351872 goroutines=343 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=15m45.06s requests=30567760 failures=0 heapBytes=4157600 rssBytes=22609920 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=16m0.06s requests=31051568 failures=0 heapBytes=4455056 rssBytes=22544384 goroutines=369 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=16m15.06s requests=31534567 failures=0 heapBytes=6660704 rssBytes=22876160 goroutines=271 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=16m30.06s requests=32010137 failures=0 heapBytes=6978528 rssBytes=22081536 goroutines=359 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=16m45.06s requests=32497005 failures=0 heapBytes=6214880 rssBytes=21352448 goroutines=252 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=17m0.06s requests=32977986 failures=0 heapBytes=4009360 rssBytes=21745664 goroutines=265 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=17m15.06s requests=33456000 failures=0 heapBytes=6263808 rssBytes=23236608 goroutines=344 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=17m30.06s requests=33943796 failures=0 heapBytes=4312032 rssBytes=22126592 goroutines=316 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=17m45.06s requests=34422999 failures=0 heapBytes=4311080 rssBytes=21950464 goroutines=237 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=18m0.06s requests=34905892 failures=0 heapBytes=4459192 rssBytes=22847488 goroutines=378 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=18m15.06s requests=35387063 failures=0 heapBytes=4965536 rssBytes=22679552 goroutines=272 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=18m30.06s requests=35872523 failures=0 heapBytes=7317240 rssBytes=22810624 goroutines=356 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=18m45.06s requests=36357886 failures=0 heapBytes=7180144 rssBytes=23207936 goroutines=379 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=19m0.06s requests=36847661 failures=0 heapBytes=6535800 rssBytes=22257664 goroutines=260 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=19m15.06s requests=37333781 failures=0 heapBytes=4758968 rssBytes=21053440 goroutines=261 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=19m30.06s requests=37819005 failures=0 heapBytes=5854656 rssBytes=22798336 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=19m45.06s requests=38302079 failures=0 heapBytes=6477368 rssBytes=23130112 goroutines=231 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=20m0.06s requests=38781438 failures=0 heapBytes=4912296 rssBytes=22179840 goroutines=291 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=20m15.06s requests=39266527 failures=0 heapBytes=6723304 rssBytes=22417408 goroutines=267 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=20m30.06s requests=39747087 failures=0 heapBytes=4455048 rssBytes=22949888 goroutines=282 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=20m45.06s requests=40229194 failures=0 heapBytes=7650552 rssBytes=22667264 goroutines=314 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=21m0.06s requests=40719856 failures=0 heapBytes=5024096 rssBytes=21934080 goroutines=327 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=21m15.06s requests=41201734 failures=0 heapBytes=4340216 rssBytes=22867968 goroutines=372 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=21m30.06s requests=41687427 failures=0 heapBytes=6020200 rssBytes=22179840 goroutines=356 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=21m45.06s requests=42169416 failures=0 heapBytes=5102400 rssBytes=21901312 goroutines=284 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=22m0.06s requests=42649854 failures=0 heapBytes=5587144 rssBytes=23244800 goroutines=257 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=22m15.061s requests=43134689 failures=0 heapBytes=7256936 rssBytes=22966272 goroutines=247 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=22m30.06s requests=43615393 failures=0 heapBytes=7194832 rssBytes=23044096 goroutines=370 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=22m45.06s requests=44094551 failures=0 heapBytes=4162944 rssBytes=22044672 goroutines=298 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=23m0.06s requests=44581507 failures=0 heapBytes=5801200 rssBytes=21823488 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=23m15.06s requests=45066149 failures=0 heapBytes=4212824 rssBytes=22265856 goroutines=359 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=23m30.06s requests=45549300 failures=0 heapBytes=4478232 rssBytes=21921792 goroutines=340 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=23m45.06s requests=46035904 failures=0 heapBytes=6875824 rssBytes=22740992 goroutines=249 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=24m0.06s requests=46519641 failures=0 heapBytes=6247888 rssBytes=21938176 goroutines=291 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=24m15.06s requests=46997264 failures=0 heapBytes=7072560 rssBytes=23109632 goroutines=377 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=24m30.06s requests=47482230 failures=0 heapBytes=5093112 rssBytes=23351296 goroutines=392 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=24m45.06s requests=47955814 failures=0 heapBytes=5715440 rssBytes=22499328 goroutines=350 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=25m0.06s requests=48434219 failures=0 heapBytes=5736984 rssBytes=22720512 goroutines=336 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=25m15.06s requests=48911328 failures=0 heapBytes=7415632 rssBytes=22880256 goroutines=256 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=25m30.06s requests=49400802 failures=0 heapBytes=3811240 rssBytes=21762048 goroutines=347 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=25m45.06s requests=49885035 failures=0 heapBytes=4371400 rssBytes=22343680 goroutines=374 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=26m0.06s requests=50365248 failures=0 heapBytes=4201696 rssBytes=23511040 goroutines=324 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=26m15.06s requests=50845851 failures=0 heapBytes=4646712 rssBytes=22171648 goroutines=274 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=26m30.06s requests=51332514 failures=0 heapBytes=4957128 rssBytes=22810624 goroutines=264 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=26m45.06s requests=51818521 failures=0 heapBytes=5534424 rssBytes=22429696 goroutines=361 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=27m0.06s requests=52303713 failures=0 heapBytes=5460112 rssBytes=22978560 goroutines=264 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=27m15.06s requests=52785633 failures=0 heapBytes=6919824 rssBytes=22196224 goroutines=293 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=27m30.06s requests=53270255 failures=0 heapBytes=6976928 rssBytes=22519808 goroutines=273 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=27m45.06s requests=53754584 failures=0 heapBytes=6265560 rssBytes=23269376 goroutines=300 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=28m0.06s requests=54243260 failures=0 heapBytes=6019184 rssBytes=22319104 goroutines=304 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=28m15.06s requests=54727233 failures=0 heapBytes=6646888 rssBytes=22794240 goroutines=289 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=28m30.06s requests=55211460 failures=0 heapBytes=7514304 rssBytes=22654976 goroutines=345 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=28m45.06s requests=55691054 failures=0 heapBytes=4597656 rssBytes=21585920 goroutines=337 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:26}
|
||||
soak_test.go:96: phase=running elapsed=29m0.06s requests=56175794 failures=0 heapBytes=5366888 rssBytes=22618112 goroutines=240 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=29m15.06s requests=56657414 failures=0 heapBytes=5976448 rssBytes=22413312 goroutines=393 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=29m30.06s requests=57140848 failures=0 heapBytes=4945864 rssBytes=23572480 goroutines=276 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=29m45.06s requests=57624110 failures=0 heapBytes=4265224 rssBytes=22007808 goroutines=164 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=30m0.06s requests=58102348 failures=0 heapBytes=5912816 rssBytes=21807104 goroutines=333 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:13}
|
||||
soak_test.go:166: phase=after elapsed=30m0.067s requests=58102476 failures=0 heapBytes=4154504 rssBytes=21807104 goroutines=3 fileDescriptors=6 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestTLSSoak (1800.07s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 1800.073s
|
||||
PASS HTTP/2 TLS soak
|
||||
265
performance/linuxSoak.json
Normal file
265
performance/linuxSoak.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
265
performance/linuxSoakTLS.json
Normal file
265
performance/linuxSoakTLS.json
Normal 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
1735
performance/tcpBaseline.json
Normal file
File diff suppressed because it is too large
Load Diff
592
performance/tlsBaseline.json
Normal file
592
performance/tlsBaseline.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
592
performance/tlsBaselineLinux.json
Normal file
592
performance/tlsBaselineLinux.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
136
scripts/verifyBusiness.py
Executable file
136
scripts/verifyBusiness.py
Executable file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a real generated business module across a released framework upgrade."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from verifyScaffold import MODULE, run
|
||||
|
||||
|
||||
def free_address():
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return f"127.0.0.1:{listener.getsockname()[1]}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--go", default="go")
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
|
||||
env = dict(os.environ, GOWORK="off")
|
||||
env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"]))
|
||||
with tempfile.TemporaryDirectory(prefix="go omni business ") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
env["GOBIN"] = str(root / "bin")
|
||||
print(f"Installing released CLI {args.version}", flush=True)
|
||||
run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], root, env)
|
||||
cli = root / "bin" / "go-omni"
|
||||
|
||||
project = root / "billing service"
|
||||
run([
|
||||
str(cli), "new", "--database", "memory", "--framework-version",
|
||||
args.from_version, "--module", "example.com/billing", str(project),
|
||||
], root, env)
|
||||
run([args.go, "mod", "tidy"], project, env)
|
||||
manifest = (project / "go.mod").read_text()
|
||||
if f"{MODULE} {args.from_version}" not in manifest or "replace " in manifest:
|
||||
raise AssertionError("initial business project was not pinned to the expected remote version")
|
||||
run([args.go, "test", "-race", "./..."], project, env)
|
||||
run([args.go, "get", f"{MODULE}@{args.version}"], project, env)
|
||||
run([args.go, "mod", "tidy"], project, env)
|
||||
upgraded = (project / "go.mod").read_text()
|
||||
if f"{MODULE} {args.version}" not in upgraded or "replace " in upgraded:
|
||||
raise AssertionError("business project upgrade did not produce a clean remote pin")
|
||||
run([args.go, "test", "-race", "./..."], project, env)
|
||||
binary = project / "billing-server"
|
||||
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
|
||||
|
||||
address = free_address()
|
||||
log_file = project / "business.log"
|
||||
server_env = dict(env)
|
||||
server_env.update(
|
||||
GO_OMNI_HTTP_ADDR=address,
|
||||
GO_OMNI_ENABLE_LOGGER="true",
|
||||
GO_OMNI_LOG_FILE=str(log_file),
|
||||
GO_OMNI_LOG_FORMAT="json",
|
||||
GO_OMNI_SHUTDOWN_TIMEOUT="2s",
|
||||
)
|
||||
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
def request(method, path, body=None, request_id="business-request"):
|
||||
payload = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
f"http://{address}{path}", data=payload, method=method,
|
||||
headers={"Content-Type": "application/json", "X-Request-ID": request_id},
|
||||
)
|
||||
try:
|
||||
with client.open(req, timeout=3) as response:
|
||||
return response.status, response.headers, json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, error.headers, json.loads(error.read())
|
||||
|
||||
process = subprocess.Popen(
|
||||
[str(binary)], cwd=project, env=server_env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
output = process.stdout.read() if process.stdout else ""
|
||||
raise RuntimeError(f"business service exited during startup: {output}")
|
||||
try:
|
||||
status, _, _ = request("GET", "/health/live")
|
||||
if status == 200:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("business service did not start")
|
||||
time.sleep(0.05)
|
||||
|
||||
status, headers, body = request("GET", "/health/ready")
|
||||
if status != 200 or body.get("code") != "READY":
|
||||
raise AssertionError(f"readiness failed: {status} {body}")
|
||||
status, headers, body = request("POST", "/users", {"name": "broken"}, "invalid-request")
|
||||
if status != 400 or body.get("code") != "INVALID_BODY" or headers.get("X-Request-ID") != "invalid-request":
|
||||
raise AssertionError(f"validation contract failed: {status} {body}")
|
||||
status, _, body = request("POST", "/users", {"name": "Alice", "email": "alice@example.com"}, "create-request")
|
||||
if status != 201 or body.get("data", {}).get("email") != "alice@example.com":
|
||||
raise AssertionError(f"business create failed: {status} {body}")
|
||||
status, _, body = request("POST", "/users", {"name": "Duplicate", "email": "ALICE@example.com"}, "duplicate-request")
|
||||
if status != 409 or body.get("code") != "EMAIL_TAKEN":
|
||||
raise AssertionError(f"business error mapping failed: {status} {body}")
|
||||
process.send_signal(signal.SIGTERM)
|
||||
if process.wait(timeout=8) != 0:
|
||||
raise AssertionError("business service did not exit cleanly")
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
content = log_file.read_text()
|
||||
if '"http.request"' not in content or '"requestId":"create-request"' not in content:
|
||||
raise AssertionError(f"access log did not contain business request correlation: {content}")
|
||||
if '"service":"example.com/billing"' not in content:
|
||||
raise AssertionError(f"business service name was not attached to logs: {content}")
|
||||
if "private" in content.lower() and "password" in content.lower():
|
||||
raise AssertionError("sensitive text leaked into business logs")
|
||||
|
||||
print(f"PASS business module: {args.from_version} -> {args.version}, clean remote pin, CRUD, validation, errors, requestId and logs", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
265
scripts/verifyDatabase.py
Normal file
265
scripts/verifyDatabase.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a generated MySQL service against a disposable local Docker database."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from verifyScaffold import MODULE, run
|
||||
|
||||
|
||||
def waitFor(check, description, timeout=20):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if check():
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"timed out: {description}")
|
||||
|
||||
|
||||
class MySQL:
|
||||
def __init__(self, directory, env, image):
|
||||
self.directory, self.env, self.image = directory, dict(env), image
|
||||
self.name = "go-omni-check-" + uuid.uuid4().hex[:12]
|
||||
self.container = None
|
||||
self.password = secrets.token_hex(18)
|
||||
self.env.update(MYSQL_ROOT_PASSWORD=secrets.token_hex(18), MYSQL_PASSWORD=self.password)
|
||||
self.env["MYSQL_PWD"] = self.env["MYSQL_ROOT_PASSWORD"]
|
||||
|
||||
def sql(self, statement, database=None):
|
||||
args = ["docker", "exec", "-i", "--env", "MYSQL_PWD", self.container,
|
||||
"mysql", "--protocol=tcp", "-h127.0.0.1", "-uroot", "--batch", "--skip-column-names"]
|
||||
if database:
|
||||
args.append(database)
|
||||
result = subprocess.run(args, cwd=self.directory, env=self.env, input=statement,
|
||||
text=True, capture_output=True, timeout=10)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"test database SQL failed: {result.stderr}")
|
||||
return result.stdout.strip()
|
||||
|
||||
def wait(self):
|
||||
def ready():
|
||||
try:
|
||||
return self.sql("SELECT 1") == "1"
|
||||
except (RuntimeError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
waitFor(ready, "MySQL ready", timeout=90)
|
||||
|
||||
def start(self):
|
||||
endpoint = self.env.get("DOCKER_HOST") or run(
|
||||
["docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"],
|
||||
self.directory, self.env).strip()
|
||||
if not endpoint.startswith(("unix://", "npipe://")):
|
||||
raise RuntimeError("database verification requires a local Docker context")
|
||||
# Docker may assign a different random published port after restart.
|
||||
# Choose the loopback port once so outage/recovery keeps the same DSN.
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
hostPort = listener.getsockname()[1]
|
||||
print("Starting isolated MySQL container", flush=True)
|
||||
self.container = run(["docker", "run", "--detach", "--name", self.name,
|
||||
"--label", "go-omni.test=database", "--publish", f"127.0.0.1:{hostPort}:3306",
|
||||
"--env", "MYSQL_ROOT_PASSWORD", "--env", "MYSQL_PASSWORD",
|
||||
"--env", "MYSQL_DATABASE=omni_test", "--env", "MYSQL_USER=omni_test",
|
||||
self.image], self.directory, self.env).strip()
|
||||
self.wait()
|
||||
port = run(["docker", "port", self.container, "3306/tcp"], self.directory, self.env).strip().rsplit(":", 1)[1]
|
||||
self.dsn = (f"omni_test:{self.password}@tcp(127.0.0.1:{port})/omni_test"
|
||||
"?parseTime=true&timeout=2s&readTimeout=2s&writeTimeout=2s")
|
||||
self.sql("CREATE DATABASE omni_empty; GRANT ALL ON omni_empty.* TO 'omni_test'@'%';")
|
||||
|
||||
def close(self):
|
||||
if self.container:
|
||||
# Remove only the container and anonymous volume created by this run.
|
||||
run(["docker", "rm", "--force", "--volumes", self.container], self.directory, self.env)
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(self, binary, directory, env):
|
||||
self.binary, self.directory, self.env = binary, directory, dict(env)
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
self.address = f"127.0.0.1:{listener.getsockname()[1]}"
|
||||
self.env["GO_OMNI_HTTP_ADDR"] = self.address
|
||||
self.process = None
|
||||
self.logPath = directory / f"server-{uuid.uuid4().hex[:8]}.log"
|
||||
self.client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
def request(self, method, path, body=None, expected=200):
|
||||
req = urllib.request.Request(f"http://{self.address}{path}", method=method,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json", "X-Request-ID": "databaseCheck"})
|
||||
try:
|
||||
response = self.client.open(req, timeout=3)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
raw = response.read()
|
||||
if response.status != expected:
|
||||
raise AssertionError(f"{method} {path}: expected {expected}, got {response.status}: {raw!r}")
|
||||
if response.headers.get("X-Request-ID") != "databaseCheck":
|
||||
raise AssertionError("missing requestId header")
|
||||
if not raw:
|
||||
return None
|
||||
result = json.loads(raw)
|
||||
if result.get("requestId") != "databaseCheck":
|
||||
raise AssertionError("missing camelCase requestId")
|
||||
return result
|
||||
|
||||
def start(self):
|
||||
with self.logPath.open("w") as logs:
|
||||
self.process = subprocess.Popen([str(self.binary)], cwd=self.directory, env=self.env,
|
||||
stdout=logs, stderr=subprocess.STDOUT)
|
||||
def ready():
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError(f"service exited: {self.logPath.read_text()}")
|
||||
try:
|
||||
return self.request("GET", "/health/live")["data"]["status"] == "ok"
|
||||
except (OSError, urllib.error.URLError):
|
||||
return False
|
||||
waitFor(ready, "service startup")
|
||||
|
||||
def stop(self):
|
||||
if self.process and self.process.poll() is None:
|
||||
self.process.send_signal(signal.SIGTERM)
|
||||
code = self.process.wait(timeout=10)
|
||||
if code != 0:
|
||||
raise AssertionError(f"service exit {code}: {self.logPath.read_text()}")
|
||||
|
||||
def close(self):
|
||||
if self.process and self.process.poll() is None:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--go", default="go")
|
||||
parser.add_argument("--version", default="v0.6.0-preview")
|
||||
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("--image", default="mysql:8.4")
|
||||
args = parser.parse_args()
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith("GO_OMNI_")}
|
||||
env["GOWORK"] = "off"
|
||||
env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE"), "git.yuntuo.hk"]))
|
||||
with tempfile.TemporaryDirectory(prefix="go omni database ") as temporary:
|
||||
directory = Path(temporary).resolve()
|
||||
if args.cli:
|
||||
cli = Path(args.cli).resolve()
|
||||
else:
|
||||
env["GOBIN"] = str(directory / "bin")
|
||||
run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], directory, env)
|
||||
cli = directory / "bin" / "go-omni"
|
||||
command = [str(cli), "new", "--module", "example.com/databaseCheck", "--database", "mysql"]
|
||||
if args.framework_dir:
|
||||
command += ["--framework-dir", str(Path(args.framework_dir).resolve())]
|
||||
project = directory / "billing"
|
||||
run(command + [str(project)], directory, env)
|
||||
module = json.loads(run([args.go, "mod", "edit", "-json"], project, env))
|
||||
if not args.framework_dir and module.get("Replace"):
|
||||
raise AssertionError("remote MySQL project contains a local replace")
|
||||
if {entry["Path"]: entry["Version"] for entry in module["Require"]}.get(MODULE) != args.version:
|
||||
raise AssertionError("MySQL project did not pin the CLI version")
|
||||
run([args.go, "mod", "tidy"], project, env)
|
||||
binary = directory / "server"
|
||||
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
|
||||
database = MySQL(directory, env, args.image)
|
||||
servers = []
|
||||
try:
|
||||
database.start()
|
||||
database.sql((project / "schema.sql").read_text(), "omni_test")
|
||||
testEnv = dict(env, GO_OMNI_TEST_MYSQL_DSN=database.dsn)
|
||||
output = run([args.go, "test", "-race", "-v", "./..."], project, testEnv)
|
||||
for name in ("TestDatabaseTransactions", "TestDatabasePoolHealthAndClose"):
|
||||
if f"--- PASS: {name}" not in output:
|
||||
raise AssertionError(f"integration test did not pass: {name}\n{output}")
|
||||
print("PASS real MySQL: commit, error/panic/cancel rollback, pool limits, health timeout, close", flush=True)
|
||||
|
||||
serverEnv = dict(env, GO_OMNI_DB_DSN=database.dsn,
|
||||
GO_OMNI_DB_PING_TIMEOUT="500ms", GO_OMNI_ENABLE_LOGGER="true")
|
||||
server = Server(binary, project, serverEnv)
|
||||
servers.append(server)
|
||||
server.start()
|
||||
ready = server.request("GET", "/health/ready")
|
||||
if ready["code"] != "READY" or ready["data"]["checks"][0]["name"] != "database":
|
||||
raise AssertionError(f"database probe not registered: {ready}")
|
||||
server.request("POST", "/users", {"name": "", "email": "invalid"}, expected=400)
|
||||
created = server.request("POST", "/users", {"name": " Alice ", "email": "ALICE@example.com"}, expected=201)
|
||||
user = created["data"]
|
||||
if user["email"] != "alice@example.com" or not user.get("createdAt"):
|
||||
raise AssertionError(f"normalization/time parsing failed: {user}")
|
||||
path = f"/users/{user['id']}"
|
||||
duplicate = server.request("POST", "/users", {"name": "Duplicate", "email": "alice@example.com"}, expected=409)
|
||||
if duplicate["code"] != "EMAIL_TAKEN":
|
||||
raise AssertionError("duplicate email error was not mapped")
|
||||
if server.request("GET", path)["data"]["name"] != "Alice":
|
||||
raise AssertionError("GET did not load created user")
|
||||
page = server.request("GET", "/users?page=1&pageSize=1")["data"]
|
||||
if page["total"] != 1 or page["pageSize"] != 1:
|
||||
raise AssertionError(f"pagination mismatch: {page}")
|
||||
server.request("PUT", path, {"name": "Updated", "email": "updated@example.com"})
|
||||
server.stop()
|
||||
waitFor(lambda: database.sql("SELECT COUNT(*) FROM information_schema.processlist WHERE USER='omni_test'") == "0",
|
||||
"connection pool closed on service exit")
|
||||
server = Server(binary, project, serverEnv)
|
||||
servers.append(server)
|
||||
server.start()
|
||||
if server.request("GET", path)["data"]["name"] != "Updated":
|
||||
raise AssertionError("data did not survive service restart")
|
||||
print("PASS HTTP validation, unique email, create/read/update/list, restart persistence and pool cleanup", flush=True)
|
||||
|
||||
failureEnv = dict(serverEnv, GO_OMNI_DB_DSN=database.dsn.replace("/omni_test?", "/omni_empty?"))
|
||||
failed = subprocess.run([str(binary)], cwd=project, env=failureEnv, capture_output=True, text=True, timeout=10)
|
||||
if failed.returncode == 0 or "apply schema.sql" not in failed.stderr:
|
||||
raise AssertionError("missing schema did not fail startup before listening")
|
||||
waitFor(lambda: database.sql("SELECT COUNT(*) FROM information_schema.processlist WHERE DB='omni_empty'") == "0",
|
||||
"connection pool closed on schema failure")
|
||||
print("PASS missing schema fails startup and releases connections", flush=True)
|
||||
|
||||
run(["docker", "stop", "--time", "10", database.container], directory, database.env)
|
||||
readiness = server.request("GET", "/health/ready", expected=503)
|
||||
if readiness["code"] != "NOT_READY" or readiness["data"]["checks"][0]["status"] != "not_ready":
|
||||
raise AssertionError("database outage did not fail readiness")
|
||||
server.request("GET", "/health/live")
|
||||
unavailable = server.request("GET", path, expected=500)
|
||||
if unavailable["code"] != "INTERNAL_ERROR" or database.password in json.dumps(unavailable):
|
||||
raise AssertionError("database outage leaked an internal error")
|
||||
failed = subprocess.run([str(binary)], cwd=project, env=serverEnv, capture_output=True, text=True, timeout=10)
|
||||
if failed.returncode == 0 or "ping database" not in failed.stderr:
|
||||
raise AssertionError("offline database did not fail startup")
|
||||
run(["docker", "start", database.container], directory, database.env)
|
||||
database.wait()
|
||||
def recovered():
|
||||
try:
|
||||
return server.request("GET", "/health/ready")["code"] == "READY"
|
||||
except (AssertionError, OSError, urllib.error.URLError):
|
||||
return False
|
||||
waitFor(recovered, "application reconnects after MySQL restart")
|
||||
if server.request("GET", path)["data"]["name"] != "Updated":
|
||||
raise AssertionError("database restart lost persistent data")
|
||||
server.request("DELETE", path, expected=204)
|
||||
server.request("GET", path, expected=404)
|
||||
server.stop()
|
||||
waitFor(lambda: database.sql("SELECT COUNT(*) FROM information_schema.processlist WHERE USER='omni_test'") == "0",
|
||||
"final connection cleanup")
|
||||
print("PASS database outage: readiness 503, liveness 200, startup failure; recovery 200; delete 204/404", flush=True)
|
||||
finally:
|
||||
for server in servers:
|
||||
server.close()
|
||||
database.close()
|
||||
print("Removed test container, anonymous volume and temporary project", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
scripts/verifyHTTP2.py
Executable file
98
scripts/verifyHTTP2.py
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the opt-in HTTP/2 TLS soak in a pinned Linux Go image."""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default="golang:1.25")
|
||||
parser.add_argument("--scenario", choices=("single", "mixed"), default="single", help="single TLS connection or eight connections with paced upload/download clients")
|
||||
parser.add_argument("--goproxy", default="", help="optional Go module proxy passed to the container")
|
||||
parser.add_argument("--duration", help="soak duration (default: 30m, or 60s with --race)")
|
||||
parser.add_argument("--timeout", default="35m", help="go test timeout (default: 35m)")
|
||||
parser.add_argument("--gomaxprocs", type=int, default=2)
|
||||
parser.add_argument("--cpus", type=float, default=2, help="container CPU quota (default: 2)")
|
||||
parser.add_argument("--memory-mb", type=int, default=2048, help="container memory cap, with no swap (default: 2048 MiB)")
|
||||
parser.add_argument("--pids-limit", type=int, default=256, help="container process/thread cap (default: 256)")
|
||||
parser.add_argument("--wall-timeout", type=int, default=2400, help="total time limit including build/download, in seconds (default: 2400)")
|
||||
parser.add_argument(
|
||||
"--race",
|
||||
action="store_true",
|
||||
help="enable the race detector for short concurrency checks; its memory overhead is not a production baseline",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.duration is None:
|
||||
args.duration = "60s" if args.race else "30m"
|
||||
if not args.duration or not args.timeout or args.gomaxprocs < 1:
|
||||
parser.error("--duration and --timeout must be non-empty and --gomaxprocs must be positive")
|
||||
if not math.isfinite(args.cpus) or args.cpus <= 0 or args.memory_mb < 128 or args.pids_limit < 16 or args.wall_timeout < 1:
|
||||
parser.error("--cpus and --wall-timeout must be positive; --memory-mb >= 128 and --pids-limit >= 16 are required")
|
||||
|
||||
docker = shutil.which("docker")
|
||||
if docker is None:
|
||||
raise SystemExit("docker is required for HTTP/2 Linux soak")
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
name = "go-omni-http2-soak-" + uuid.uuid4().hex[:12]
|
||||
command = [
|
||||
docker, "run", "--rm", "--pull=missing", "--name", name,
|
||||
"--cpus", str(args.cpus), "--memory", f"{args.memory_mb}m",
|
||||
"--memory-swap", f"{args.memory_mb}m", "--pids-limit", str(args.pids_limit),
|
||||
"--log-opt", "max-size=10m", "--log-opt", "max-file=1",
|
||||
"-v", f"{root}:/workspace:ro", "-w", "/workspace",
|
||||
"-e", "GOWORK=off", "-e", f"GOMAXPROCS={args.gomaxprocs}",
|
||||
"-e", f"GO_OMNI_HTTP2_SOAK_DURATION={args.duration}",
|
||||
]
|
||||
if args.goproxy:
|
||||
command += ["-e", f"GOPROXY={args.goproxy}"]
|
||||
command += [args.image, "go", "test"]
|
||||
if args.race:
|
||||
command.append("-race")
|
||||
command += [
|
||||
"-v", "-timeout", args.timeout, "./transport/http2", "-run",
|
||||
"^TestTLSMixedSoak$" if args.scenario == "mixed" else "^TestTLSSoak$", "-count=1",
|
||||
]
|
||||
print(f"HTTP/2 Linux soak image: {args.image}", flush=True)
|
||||
print(f"HTTP/2 TLS soak duration: {args.duration}", flush=True)
|
||||
print(f"HTTP/2 TLS soak mode: {'race' if args.race else 'regular'}", flush=True)
|
||||
print(f"HTTP/2 TLS soak scenario: {args.scenario}", flush=True)
|
||||
print(f"HTTP/2 soak container: {name}; limits: {args.cpus:g} CPUs, {args.memory_mb} MiB, {args.pids_limit} tasks", flush=True)
|
||||
|
||||
def interrupt(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
previous_handlers = {
|
||||
sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)
|
||||
}
|
||||
try:
|
||||
result = subprocess.run(command, cwd=root, timeout=args.wall_timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit("HTTP/2 Linux soak exceeded --wall-timeout; stopping its container")
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit("HTTP/2 Linux soak interrupted; stopping its container")
|
||||
finally:
|
||||
for sig, handler in previous_handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
# Only this run's unique container is targeted, including on timeout or
|
||||
# a disconnected SSH session. Do not prune other jobs.
|
||||
try:
|
||||
cleanup = subprocess.run(
|
||||
[docker, "rm", "--force", name], capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if cleanup.returncode != 0 and "No such container" not in cleanup.stderr:
|
||||
raise SystemExit(f"Could not clean up {name}: {cleanup.stderr.strip()}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit(f"Cleanup timed out; inspect container {name} before another run")
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"HTTP/2 Linux soak failed with exit code {result.returncode}")
|
||||
print("PASS HTTP/2 TLS soak", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
177
scripts/verifyHTTP2Proxy.py
Normal file
177
scripts/verifyHTTP2Proxy.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify real Nginx TLS HTTP/2 upstreams inside one bounded, isolated container."""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
class ContainerRunner:
|
||||
def __init__(self, docker, output, deadline):
|
||||
self.docker = docker
|
||||
self.output = output
|
||||
self.deadline = deadline
|
||||
self.commands = []
|
||||
self.cleaned = []
|
||||
|
||||
def execute(self, command):
|
||||
remaining = self.deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("HTTP/2 proxy verification exceeded its wall timeout")
|
||||
self.commands.append(command)
|
||||
result = subprocess.run(command, stdout=self.output, stderr=subprocess.STDOUT, timeout=remaining)
|
||||
self.output.flush()
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"command failed with exit code {result.returncode}: {command[1:3]}")
|
||||
|
||||
def container(self, name, arguments):
|
||||
command = [
|
||||
self.docker, "run", "--rm", "--name", name,
|
||||
"--cpus", "2", "--memory", "2048m", "--memory-swap", "2048m", "--pids-limit", "256",
|
||||
"--log-opt", "max-size=10m", "--log-opt", "max-file=1",
|
||||
] + arguments
|
||||
try:
|
||||
self.execute(command)
|
||||
finally:
|
||||
# Always finish cleanup before starting the next stage. Never prune
|
||||
# or target a container belonging to another verification run.
|
||||
cleanup = subprocess.run(
|
||||
[self.docker, "rm", "--force", name], capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if cleanup.returncode and "No such container" not in cleanup.stderr:
|
||||
raise RuntimeError(f"could not clean up {name}: {cleanup.stderr.strip()}")
|
||||
self.cleaned.append(name)
|
||||
|
||||
|
||||
def sourceManifest(root):
|
||||
paths = [root / "go.mod", root / "go.sum", Path(__file__).resolve()]
|
||||
paths += list((root / "transport" / "http2").glob("*.go"))
|
||||
return {str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(paths)}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default="golang:1.25", help="compiler image")
|
||||
parser.add_argument("--nginx-image", help="Nginx image supporting proxy_http_version 2; defaults to nginx:1.30.5-alpine, or nginx:1.30.5 for --race")
|
||||
parser.add_argument("--race", action="store_true", help="instrument the integration test with Go's race detector; requires a glibc-based runtime image")
|
||||
parser.add_argument("--goproxy", default="")
|
||||
parser.add_argument("--count", type=int, default=3, help="repeat the proxy scenarios (default: 3)")
|
||||
parser.add_argument("--wall-timeout", type=int, default=600, help="total pull/build/test deadline in seconds")
|
||||
parser.add_argument("--output", type=Path, default=Path("performance/http2ProxyLinux.json"))
|
||||
args = parser.parse_args()
|
||||
if args.nginx_image is None:
|
||||
args.nginx_image = "nginx:1.30.5" if args.race else "nginx:1.30.5-alpine"
|
||||
if args.count < 1 or args.count > 10 or args.wall_timeout < 1:
|
||||
parser.error("--count must be 1..10 and --wall-timeout must be positive")
|
||||
docker = shutil.which("docker")
|
||||
if not docker:
|
||||
raise SystemExit("docker is required")
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
args.output = args.output.resolve()
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
logPath = args.output.with_suffix(".txt")
|
||||
runID = uuid.uuid4().hex[:12]
|
||||
names = [f"go-omni-http2-proxy-{runID}-{stage}" for stage in ("build", "check")]
|
||||
capture = lambda command: subprocess.check_output(command, text=True, timeout=15).strip()
|
||||
running = capture([docker, "ps", "--format", "{{.ID}} {{.Names}}"])
|
||||
if any("go-omni-http2-" in row for row in running.splitlines()):
|
||||
raise SystemExit("another HTTP/2 validation container is running; wait for it to finish")
|
||||
before = {row.split()[0] for row in running.splitlines()}
|
||||
report = {
|
||||
"status": "running", "startedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"testedCommit": capture(["git", "-C", str(root), "rev-parse", "HEAD"]),
|
||||
"workingTreeStatus": capture(["git", "-C", str(root), "status", "--porcelain"]),
|
||||
"sourceSha256": sourceManifest(root), "repetitions": args.count, "race": args.race,
|
||||
"containerLimits": {"cpus": 2, "memoryBytes": 2147483648, "memoryAndSwapBytes": 2147483648, "pidsLimit": 256},
|
||||
"wallTimeoutSeconds": args.wall_timeout, "imageIds": {},
|
||||
"notes": [
|
||||
"Compiler and integration stages run sequentially; at most one 2-CPU/2-GiB validation container is active.",
|
||||
"The integration container has network=none, no published ports and a read-only root filesystem. Client, Nginx and origins communicate over loopback inside that container.",
|
||||
"Both TLS legs validate certificates; invalid upstream CA and hostname must fail before reaching the handler.",
|
||||
"HTTP/2 is verified independently on both legs. This is a functional integration test, not a throughput benchmark or a live Cloudflare deployment.",
|
||||
],
|
||||
}
|
||||
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
def interrupt(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
handlers = {sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)}
|
||||
failure = None
|
||||
with logPath.open("w") as output:
|
||||
runner = ContainerRunner(docker, output, time.monotonic() + args.wall_timeout)
|
||||
try:
|
||||
for imageName in (args.image, args.nginx_image):
|
||||
inspected = subprocess.run([docker, "image", "inspect", imageName, "--format", "{{.Id}}"], capture_output=True, text=True, timeout=15)
|
||||
if inspected.returncode:
|
||||
print(f"Pulling {imageName}", flush=True)
|
||||
runner.execute([docker, "pull", imageName])
|
||||
report["imageIds"][imageName] = capture([docker, "image", "inspect", imageName, "--format", "{{.Id}}"])
|
||||
with tempfile.TemporaryDirectory(prefix="goOmniHTTP2Proxy") as temporary:
|
||||
print("Building proxy integration tests; limits: 2 CPUs, 2048 MiB, 256 tasks", flush=True)
|
||||
arguments = [
|
||||
"-v", f"{root}:/workspace:ro", "-v", f"{temporary}:/output", "-w", "/workspace",
|
||||
"-e", "GOWORK=off", "-e", "GOMAXPROCS=2", "-e", f"CGO_ENABLED={1 if args.race else 0}",
|
||||
]
|
||||
if args.goproxy:
|
||||
arguments += ["-e", f"GOPROXY={args.goproxy}"]
|
||||
raceFlag = "-race " if args.race else ""
|
||||
arguments += [args.image, "sh", "-c", f"go version && go vet ./transport/http2 && go test {raceFlag}-c -o /output/http2.test ./transport/http2"]
|
||||
runner.container(names[0], arguments)
|
||||
print(f"Running Nginx HTTP/2 proxy scenarios, {args.count} repetitions; external networking disabled", flush=True)
|
||||
runner.container(names[1], [
|
||||
"--network", "none", "--read-only", "--tmpfs", "/tmp:rw,nosuid,noexec,size=128m",
|
||||
"-v", f"{temporary}:/test:ro", "--entrypoint", "/test/http2.test",
|
||||
"-e", "GO_OMNI_HTTP2_PROXY_TEST=1", "-e", "GOMAXPROCS=2",
|
||||
args.nginx_image, "-test.v", "-test.run", "^TestNginxHTTP2Proxy$",
|
||||
"-test.timeout", "120s", "-test.count", str(args.count),
|
||||
])
|
||||
log = logPath.read_text()
|
||||
for case in ("TLSAndConnectionReuse", "TimeoutAndCancellation", "GoAwayAndRollingDrain"):
|
||||
if len(re.findall(r"--- PASS: TestNginxHTTP2Proxy/" + case + r" \(", log)) != args.count:
|
||||
raise RuntimeError("missing successful integration case: " + case)
|
||||
report["proxyVersions"] = sorted(set(re.findall(r"proxyVersion=(.+)", log)))
|
||||
report["results"] = re.findall(r"proxyResult=(.+)", log)
|
||||
version = re.search(r"^go version (\S+) (\S+)$", log, re.MULTILINE)
|
||||
if version is None or not report["proxyVersions"]:
|
||||
raise RuntimeError("missing Go or Nginx version evidence")
|
||||
report["goVersion"], report["platform"] = version.groups()
|
||||
report["status"] = "passed"
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
failure = str(error) or type(error).__name__
|
||||
report["status"], report["error"] = "failed", failure
|
||||
finally:
|
||||
for sig, handler in handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
report["commands"] = runner.commands
|
||||
report["cleanedContainers"] = runner.cleaned
|
||||
report["finishedAt"] = datetime.now(timezone.utc).isoformat()
|
||||
output.flush()
|
||||
report["logSha256"] = hashlib.sha256(logPath.read_bytes()).hexdigest()
|
||||
try:
|
||||
after = set(capture([docker, "ps", "--format", "{{.ID}}"]).split())
|
||||
if not before.issubset(after):
|
||||
raise RuntimeError("a pre-existing container is no longer running")
|
||||
report["preExistingContainersStillRunning"] = len(before)
|
||||
if set(runner.cleaned) != set(names) and failure is None:
|
||||
raise RuntimeError("container cleanup was not confirmed")
|
||||
except Exception as error:
|
||||
failure = str(error)
|
||||
report["status"], report["error"] = "failed", failure
|
||||
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
||||
if failure:
|
||||
raise SystemExit(f"FAIL HTTP/2 proxy verification: {failure}; see {logPath}")
|
||||
print(f"PASS HTTP/2 Nginx proxy verification; report: {args.output}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
59
scripts/verifyHTTP2Proxy_test.py
Normal file
59
scripts/verifyHTTP2Proxy_test.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Keep proxy integration failures and interrupts inside their resource envelope."""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("proxyRunner", Path(__file__).with_name("verifyHTTP2Proxy.py"))
|
||||
runnerModule = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(runnerModule)
|
||||
|
||||
|
||||
class ContainerCleanupTests(unittest.TestCase):
|
||||
def testFailedAndInterruptedStagesRemoveOnlyTheirContainer(self):
|
||||
failures = (subprocess.TimeoutExpired("docker", 1), KeyboardInterrupt(), subprocess.CompletedProcess([], 7))
|
||||
for failure in failures:
|
||||
with self.subTest(failure=type(failure).__name__):
|
||||
calls = []
|
||||
|
||||
def execute(command, **kwargs):
|
||||
calls.append(command)
|
||||
if command[1] == "run":
|
||||
if isinstance(failure, BaseException):
|
||||
raise failure
|
||||
return failure
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
runner = runnerModule.ContainerRunner("/fake/docker", io.StringIO(), time.monotonic() + 10)
|
||||
with patch.object(runnerModule.subprocess, "run", side_effect=execute):
|
||||
with self.assertRaises((subprocess.TimeoutExpired, KeyboardInterrupt, RuntimeError)):
|
||||
runner.container("owned-proxy-check", ["nginx:test"])
|
||||
self.assertEqual(calls[1:], [["/fake/docker", "rm", "--force", "owned-proxy-check"]])
|
||||
self.assertEqual(runner.cleaned, ["owned-proxy-check"])
|
||||
|
||||
def testCleanupFailureRemainsAnError(self):
|
||||
runner = runnerModule.ContainerRunner("/fake/docker", io.StringIO(), time.monotonic() + 10)
|
||||
with patch.object(runnerModule.subprocess, "run", side_effect=[
|
||||
subprocess.CompletedProcess([], 0),
|
||||
subprocess.CompletedProcess([], 1, "", "Docker daemon unavailable"),
|
||||
]):
|
||||
with self.assertRaisesRegex(RuntimeError, "could not clean up"):
|
||||
runner.container("owned-proxy-check", ["nginx:test"])
|
||||
self.assertEqual(runner.cleaned, [])
|
||||
|
||||
def testDeadlinePreventsStartingAnotherContainer(self):
|
||||
runner = runnerModule.ContainerRunner("/fake/docker", io.StringIO(), time.monotonic() - 1)
|
||||
with patch.object(runnerModule.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "", "")) as execute:
|
||||
with self.assertRaises(TimeoutError):
|
||||
runner.container("not-started", ["nginx:test"])
|
||||
self.assertEqual(execute.call_args.args[0], ["/fake/docker", "rm", "--force", "not-started"])
|
||||
self.assertEqual(execute.call_count, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
65
scripts/verifyHTTP2_test.py
Normal file
65
scripts/verifyHTTP2_test.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Regression checks for stress-container cleanup on failure and interruption."""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("http2Runner", Path(__file__).with_name("verifyHTTP2.py"))
|
||||
runner = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(runner)
|
||||
|
||||
|
||||
class CleanupTests(unittest.TestCase):
|
||||
def testFailedRunsCleanUpOnlyTheirOwnContainer(self):
|
||||
failures = (
|
||||
subprocess.TimeoutExpired("docker", 1),
|
||||
KeyboardInterrupt(),
|
||||
subprocess.CompletedProcess([], 7),
|
||||
)
|
||||
for failure in failures:
|
||||
with self.subTest(failure=type(failure).__name__):
|
||||
calls = []
|
||||
original_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGHUP)}
|
||||
|
||||
def execute(command, **kwargs):
|
||||
calls.append(command)
|
||||
if command[1] == "run":
|
||||
if isinstance(failure, BaseException):
|
||||
raise failure
|
||||
return failure
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
with patch.object(sys, "argv", ["verifyHTTP2.py", "--duration", "1s"]), \
|
||||
patch.object(runner.shutil, "which", return_value="/fake/docker"), \
|
||||
patch.object(runner.subprocess, "run", side_effect=execute), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
runner.main()
|
||||
self.assertNotEqual(raised.exception.code, 0)
|
||||
name = calls[0][calls[0].index("--name") + 1]
|
||||
self.assertEqual(calls[1:], [["/fake/docker", "rm", "--force", name]])
|
||||
self.assertEqual(original_handlers, {sig: signal.getsignal(sig) for sig in original_handlers})
|
||||
|
||||
def testCleanupFailureIsNotReportedAsPass(self):
|
||||
results = [
|
||||
subprocess.CompletedProcess([], 0),
|
||||
subprocess.CompletedProcess([], 1, "", "Cannot connect to the Docker daemon"),
|
||||
]
|
||||
with patch.object(sys, "argv", ["verifyHTTP2.py", "--duration", "1s"]), \
|
||||
patch.object(runner.shutil, "which", return_value="/fake/docker"), \
|
||||
patch.object(runner.subprocess, "run", side_effect=results), \
|
||||
contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
with self.assertRaisesRegex(SystemExit, "Could not clean up"):
|
||||
runner.main()
|
||||
self.assertNotIn("PASS", output.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
87
scripts/verifyLinux.py
Executable file
87
scripts/verifyLinux.py
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/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("--goproxy", default="", help="optional Go module proxy passed to the container")
|
||||
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}",
|
||||
]
|
||||
if args.goproxy:
|
||||
docker_args += ["-e", f"GOPROXY={args.goproxy}"]
|
||||
docker_args += [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()
|
||||
55
scripts/verifyPerformance.py
Executable file
55
scripts/verifyPerformance.py
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run GoOmni's repeatable stability checks and transport performance baseline."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--go", default="go")
|
||||
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()
|
||||
if args.count < 1:
|
||||
parser.error("--count must be positive")
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
env = dict(os.environ, GOWORK="off")
|
||||
checks = [
|
||||
([args.go, "test", "-race", "./..."], "race tests"),
|
||||
([args.go, "vet", "./..."], "vet"),
|
||||
([args.go, "build", "./..."], "build"),
|
||||
(
|
||||
[args.go, "test", "-run", "^$", "-bench", "^BenchmarkTransport",
|
||||
"-benchmem", "-count", str(args.count), "./transport"],
|
||||
"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:
|
||||
print(f"Running {name}", flush=True)
|
||||
result = subprocess.run(command, cwd=root, env=env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
print(result.stdout, end="")
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"{name} failed with exit code {result.returncode}")
|
||||
print("PASS stability checks and transport performance baseline", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
313
scripts/verifyProxy.py
Normal file
313
scripts/verifyProxy.py
Normal file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify an isolated Nginx -> native TLS reverse-proxy deployment on Linux."""
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def wait_for_native(process: subprocess.Popen[str], timeout: float) -> int:
|
||||
selector = selectors.DefaultSelector()
|
||||
assert process.stdout is not None
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"native server exited with code {process.returncode}")
|
||||
for _, _ in selector.select(timeout=0.5):
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
address = value.get("address", "")
|
||||
if address:
|
||||
return int(address.rsplit(":", 1)[1])
|
||||
raise RuntimeError("native TLS server did not announce a listener")
|
||||
finally:
|
||||
selector.close()
|
||||
|
||||
|
||||
def write_edge_certificate(directory: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", str(directory / "key.pem"),
|
||||
"-out", str(directory / "cert.pem"),
|
||||
"-days", "1", "-subj", "/CN=localhost",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def write_nginx_config(path: Path, backend_port: int, edge_port: int) -> None:
|
||||
path.write_text(
|
||||
f"""events {{
|
||||
worker_connections 512;
|
||||
}}
|
||||
|
||||
http {{
|
||||
upstream go_omni_native {{
|
||||
server 127.0.0.1:{backend_port};
|
||||
keepalive 64;
|
||||
}}
|
||||
|
||||
server {{
|
||||
listen 127.0.0.1:{edge_port} ssl;
|
||||
http2 on;
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Request-ID $http_x_request_id;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 1s;
|
||||
proxy_read_timeout 300ms;
|
||||
proxy_send_timeout 1s;
|
||||
|
||||
location / {{
|
||||
proxy_pass https://go_omni_native;
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name off;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def request(connection: http.client.HTTPSConnection, path: str) -> tuple[int, dict[str, str], bytes]:
|
||||
connection.request("GET", path, headers={"X-Request-ID": "proxyCheck"})
|
||||
response = connection.getresponse()
|
||||
body = response.read()
|
||||
return response.status, dict(response.headers), body
|
||||
|
||||
|
||||
def header(headers: dict[str, str], name: str) -> str:
|
||||
for key, value in headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def verify_keep_alive(edge_port: int) -> None:
|
||||
context = ssl._create_unverified_context()
|
||||
connection = http.client.HTTPSConnection("127.0.0.1", edge_port, context=context, timeout=3)
|
||||
expected = b'{"code":"OK","message":"success","data":{"status":"ok"},"requestId":"proxyCheck"}\n'
|
||||
try:
|
||||
for attempt in range(5):
|
||||
status, headers, body = request(connection, "/health")
|
||||
if status != 200 or header(headers, "X-Request-ID") != "proxyCheck" or body != expected:
|
||||
raise AssertionError(f"unexpected proxied response: status={status} body={body!r}")
|
||||
if attempt < 4 and getattr(connection, "sock", None) is None:
|
||||
raise AssertionError("Nginx did not keep the TLS client connection alive")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def wait_for_proxy(edge_port: int, timeout: float) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error = "proxy did not return a healthy response"
|
||||
while time.monotonic() < deadline:
|
||||
connection = None
|
||||
try:
|
||||
context = ssl._create_unverified_context()
|
||||
connection = http.client.HTTPSConnection("127.0.0.1", edge_port, context=context, timeout=2)
|
||||
status, headers, body = request(connection, "/health")
|
||||
if status == 200 and header(headers, "X-Request-ID") == "proxyCheck" and body.endswith(b"\n"):
|
||||
return
|
||||
last_error = f"status={status} body={body!r}"
|
||||
except (OSError, http.client.HTTPException) as error:
|
||||
last_error = str(error)
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"Nginx proxy did not become healthy: {last_error}")
|
||||
|
||||
|
||||
def verify_http2(edge_port: int) -> None:
|
||||
expected = b'{"code":"OK","message":"success","data":{"status":"ok"},"requestId":"proxyCheck"}\n'
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl", "--silent", "--show-error", "--fail", "--http2", "--insecure",
|
||||
"-H", "X-Request-ID: proxyCheck", "-w", "\\n%{http_version}",
|
||||
f"https://127.0.0.1:{edge_port}/health",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(f"HTTP/2 curl failed: {result.stderr.decode(errors='replace')}")
|
||||
output = result.stdout
|
||||
try:
|
||||
body, version = output.rsplit(b"\n", 1)
|
||||
except ValueError as error:
|
||||
raise AssertionError(f"HTTP/2 curl output missing protocol version: {output!r}") from error
|
||||
if version != b"2" or body != expected:
|
||||
raise AssertionError(f"expected HTTP/2 response, got version={version!r} body={body!r}")
|
||||
|
||||
|
||||
def verify_timeout(edge_port: int) -> None:
|
||||
context = ssl._create_unverified_context()
|
||||
connection = http.client.HTTPSConnection("127.0.0.1", edge_port, context=context, timeout=3)
|
||||
try:
|
||||
status, _, _ = request(connection, "/slow")
|
||||
if status != 504:
|
||||
raise AssertionError(f"expected Nginx upstream timeout 504, got {status}")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default="golang:1.25")
|
||||
parser.add_argument("--nginx-image", default="nginx:1.27-alpine")
|
||||
parser.add_argument("--goproxy", default="", help="optional Go module proxy passed to the backend container")
|
||||
args = parser.parse_args()
|
||||
if shutil.which("docker") is None:
|
||||
raise SystemExit("docker is required for proxy verification")
|
||||
if shutil.which("openssl") is None:
|
||||
raise SystemExit("openssl is required for proxy verification")
|
||||
if shutil.which("curl") is None:
|
||||
raise SystemExit("curl with HTTP/2 support is required for proxy verification")
|
||||
if not Path("/proc").exists():
|
||||
raise SystemExit("proxy verification must run on Linux")
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
backend_name = "go-omni-proxy-native"
|
||||
nginx_name = "go-omni-proxy-nginx"
|
||||
backend = None
|
||||
nginx = None
|
||||
with tempfile.TemporaryDirectory(prefix="go-omni-proxy-") as temporary:
|
||||
directory = Path(temporary)
|
||||
edge_port = free_port()
|
||||
write_edge_certificate(directory)
|
||||
backend_command = [
|
||||
"docker", "run", "--rm", "-i", "--pull=missing", "--name", backend_name,
|
||||
"--network", "host", "--cpus=1", "--memory=512m", "--pids-limit=512",
|
||||
"-v", f"{root}:/workspace", "-w", "/workspace", "-e", "GOWORK=off",
|
||||
]
|
||||
if args.goproxy:
|
||||
backend_command += ["-e", f"GOPROXY={args.goproxy}"]
|
||||
backend_command += [args.image, "sh", "-c", "go run ./internal/perfcheck --server native --tls"]
|
||||
backend = subprocess.Popen(
|
||||
backend_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
try:
|
||||
backend_port = wait_for_native(backend, 120)
|
||||
write_nginx_config(directory / "nginx.conf", backend_port, edge_port)
|
||||
print(f"proxy backend=127.0.0.1:{backend_port} edge=127.0.0.1:{edge_port}", flush=True)
|
||||
nginx = subprocess.Popen(
|
||||
[
|
||||
"docker", "run", "--rm", "--pull=missing", "--name", nginx_name,
|
||||
"--network", "host",
|
||||
"-v", f"{directory / 'nginx.conf'}:/etc/nginx/nginx.conf:ro",
|
||||
"-v", f"{directory}:/etc/nginx/certs:ro",
|
||||
args.nginx_image, "nginx", "-g", "daemon off;",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
if nginx.poll() is not None:
|
||||
raise RuntimeError(f"Nginx exited with code {nginx.returncode}")
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", edge_port), timeout=0.5):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
nginx_logs = subprocess.run(
|
||||
["docker", "logs", nginx_name], capture_output=True, text=True, check=False
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Nginx did not start listening; logs="
|
||||
f"{(nginx_logs.stdout + nginx_logs.stderr)[-4000:]!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
wait_for_proxy(edge_port, 30)
|
||||
except Exception as error:
|
||||
nginx_logs = subprocess.run(
|
||||
["docker", "logs", nginx_name], capture_output=True, text=True, check=False
|
||||
).stdout[-2000:]
|
||||
backend_logs = subprocess.run(
|
||||
["docker", "logs", backend_name], capture_output=True, text=True, check=False
|
||||
).stdout[-2000:]
|
||||
raise RuntimeError(
|
||||
f"{error}; nginx logs={nginx_logs!r}; backend logs={backend_logs!r}"
|
||||
) from error
|
||||
verify_http2(edge_port)
|
||||
print("PASS Nginx HTTP/2 edge negotiation and HTTP/1.1 native backhaul", flush=True)
|
||||
verify_keep_alive(edge_port)
|
||||
verify_timeout(edge_port)
|
||||
print("PASS Nginx TLS proxy, Keep-Alive and upstream timeout", flush=True)
|
||||
|
||||
assert backend.stdin is not None
|
||||
assert backend.stdout is not None
|
||||
backend.stdin.write('"snapshot"\n')
|
||||
backend.stdin.flush()
|
||||
print(f"native after proxy timeout: {backend.stdout.readline().strip()}", flush=True)
|
||||
backend.stdin.write('"stop"\n')
|
||||
backend.stdin.flush()
|
||||
exit_code = backend.wait(timeout=5)
|
||||
if exit_code != 0:
|
||||
details = backend.stderr.read().strip() if backend.stderr is not None else ""
|
||||
raise AssertionError(
|
||||
f"native server did not shut down cleanly behind Nginx: exit={exit_code} {details}"
|
||||
)
|
||||
print("PASS native graceful shutdown behind proxy", flush=True)
|
||||
finally:
|
||||
if nginx is not None and nginx.poll() is None:
|
||||
nginx.terminate()
|
||||
try:
|
||||
nginx.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
nginx.kill()
|
||||
nginx.wait(timeout=5)
|
||||
if backend is not None and backend.poll() is None:
|
||||
if backend.stdin is not None:
|
||||
try:
|
||||
backend.stdin.write('"stop"\n')
|
||||
backend.stdin.flush()
|
||||
except OSError:
|
||||
pass
|
||||
backend.terminate()
|
||||
try:
|
||||
backend.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
backend.kill()
|
||||
backend.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,7 +29,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
address = f"127.0.0.1:{listener.getsockname()[1]}"
|
||||
serverEnv = {key: value for key, value in env.items() if not key.startswith("GO_OMNI_")}
|
||||
serverEnv.update(GO_OMNI_HTTP_ADDR=address, GO_OMNI_MODE="release",
|
||||
serverEnv.update(GO_OMNI_HTTP_ADDR=address,
|
||||
GO_OMNI_ENABLE_LOGGER="true", GO_OMNI_SHUTDOWN_TIMEOUT="2s")
|
||||
if cleanupMarker:
|
||||
serverEnv["GO_OMNI_CLEANUP_MARKER"] = str(cleanupMarker)
|
||||
@@ -65,7 +65,8 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
|
||||
status, body = request("/health/ready")
|
||||
if status != 200 or body["code"] != "READY":
|
||||
raise AssertionError(f"unexpected readiness: {body}")
|
||||
status, user = request("/users", json.dumps({"name": "Smoke", "email": "smoke@example.com"}).encode())
|
||||
status, body = request("/users", json.dumps({"name": "Smoke", "email": "smoke@example.com"}).encode())
|
||||
user = body.get("data", body)
|
||||
if status != 201 or user["name"] != "Smoke" or "createdAt" not in user:
|
||||
raise AssertionError(f"unexpected created user: {user}")
|
||||
process.send_signal(stopSignal)
|
||||
@@ -90,7 +91,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--go", default="go", help="Go executable")
|
||||
parser.add_argument("--version", default="v0.1.1", 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("--framework-version", help="Explicit version passed to the CLI")
|
||||
parser.add_argument("--framework-dir", help="Local checkout for pre-release verification")
|
||||
@@ -141,7 +142,7 @@ import (
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
)
|
||||
func main() {
|
||||
app := omni.New(omni.WithMode("release"))
|
||||
app := omni.New()
|
||||
app.GET("/health/live", func(ctx *omni.Context) error { return ctx.OK(map[string]string{"status": "ok"}) })
|
||||
app.OnShutdown(func() error { return os.WriteFile(os.Getenv("GO_OMNI_CLEANUP_MARKER"), []byte("closed"), 0600) })
|
||||
if err := app.Run(os.Getenv("GO_OMNI_HTTP_ADDR")); err != nil { log.Fatal(err) }
|
||||
|
||||
62
scripts/verifyTransports.py
Normal file
62
scripts/verifyTransports.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the public adapter contract from an independent consumer module."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from verifyScaffold import MODULE, run
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--go", default="go")
|
||||
parser.add_argument("--version", default="v0.6.0-preview")
|
||||
parser.add_argument("--framework-dir", help="Use a local checkout before release")
|
||||
args = parser.parse_args()
|
||||
env = dict(os.environ, GOWORK="off")
|
||||
env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"]))
|
||||
with tempfile.TemporaryDirectory(prefix="go omni transport ") as temporary:
|
||||
directory = Path(temporary).resolve()
|
||||
module = f"module example.com/adaptercheck\n\ngo 1.22\n\nrequire {MODULE} {args.version}\n"
|
||||
if args.framework_dir:
|
||||
module += f"\nreplace {MODULE} => {json.dumps(str(Path(args.framework_dir).resolve()), ensure_ascii=False)}\n"
|
||||
(directory / "go.mod").write_text(module)
|
||||
(directory / "adapter_test.go").write_text(f'''package adaptercheck_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"{MODULE}/httpx"
|
||||
netrouter "{MODULE}/transport/nethttp"
|
||||
"{MODULE}/transport/transporttest"
|
||||
)
|
||||
|
||||
func TestContract(t *testing.T) {{
|
||||
t.Run("nethttp", func(t *testing.T) {{
|
||||
transporttest.Run(t, func() httpx.Router {{ return netrouter.New() }})
|
||||
}})
|
||||
}}
|
||||
''')
|
||||
source = "local checkout" if args.framework_dir else f"remote {args.version}"
|
||||
print(f"Testing {source} in an independent consumer module", flush=True)
|
||||
run([args.go, "mod", "tidy"], directory, env)
|
||||
if not args.framework_dir and "replace " in (directory / "go.mod").read_text():
|
||||
raise AssertionError("remote verification must not contain replace")
|
||||
output = run([args.go, "test", "-race", "-count=1", "-json", "./..."], directory, env)
|
||||
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||
if any(event.get("Action") == "skip" for event in events):
|
||||
raise AssertionError("contract suite unexpectedly skipped tests")
|
||||
for adapter in ("nethttp",):
|
||||
name = f"TestContract/{adapter}"
|
||||
if not any(event.get("Action") == "pass" and event.get("Test") == name for event in events):
|
||||
raise AssertionError(f"{adapter} contract did not pass")
|
||||
count = sum(event.get("Action") == "pass" and event.get("Test", "").startswith(name + "/")
|
||||
for event in events)
|
||||
print(f"PASS {adapter}: {count} contract checks, including business handlers, cancellation and concurrent requests", flush=True)
|
||||
print("PASS independent module: nethttp accepted by the public contract suite", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,22 +1,121 @@
|
||||
# Transport adapters
|
||||
|
||||
GoOmni 的业务处理链依赖 `httpx.Router`,不直接依赖具体网络框架。当前的 `transport/gin` 只负责把 Gin 的路由、路径参数和响应写入转换成标准 HTTP 接口;`transport/nethttp` 提供了一个只使用标准库的参考实现。
|
||||
|
||||
使用标准库适配器:
|
||||
业务 Handler 依赖 GoOmni 的 `httpx.Context`,Service 接收标准 `context.Context`。适配器只在启动入口组装:
|
||||
|
||||
```go
|
||||
import (
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
)
|
||||
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
registerRoutes(application) // 与 omni.New() 复用同一份业务注册代码
|
||||
```
|
||||
|
||||
后续自研网络层时,实现以下能力即可接入现有 `app`、中间件和业务 Handler:
|
||||
`transport/nethttp` 是当前内置的标准库路由实现。它提供一套可移植的路径、方法和错误回退规则;自研网络层可以直接实现同一个 `httpx.Router` 契约。
|
||||
|
||||
- `httpx.Router`:注册路由、分组并实现 `http.Handler`。
|
||||
- `httpx.RouterGroup`:注册分组内路由。
|
||||
- 适配器为请求 context 注入 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern`。
|
||||
`transport/http2` 是独立的 opt-in 实验 Runner,负责连接接入、TLS/ALPN、连接上限和全局请求背压,底层帧解析与 stream 流控暂由 `golang.org/x/net/http2` 提供。它不替换 `transport/native`,也不改变默认启动器;只有通过 `App.RunWith(ctx, http2.New(nil), address)` 显式启用才会运行。
|
||||
|
||||
业务层不应该导入具体 transport 包;它们只是网络层实现。
|
||||
`transport/native` 是第一版自研 HTTP/1.1 服务端:它自己管理 TCP listener、连接、Keep-Alive、请求解析、响应写回和优雅关闭,再把请求交给现有 `http.Handler`。请求体支持 `Content-Length` 和 chunked,并统一受 `MaxBodyBytes` 限制;其他传输编码会被拒绝。它仍使用标准 `http.Request` / `http.ResponseWriter` 作为业务桥接,因此不会影响现有 Handler / Service。
|
||||
|
||||
native 提供 `server.Stats()` 读取已接受连接数、连接拒绝数、当前连接数和当前请求数,适合和 `metrics.Metrics` 的业务请求指标一起采集;读取不会阻塞连接生命周期。`RejectedConnections` 统计连接闸门拒绝,`RejectedRequests` 统计并发请求闸门返回 503。
|
||||
|
||||
业务 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,不会无限排队,拒绝数可从 `Stats()` 采集。
|
||||
|
||||
访问日志默认只记录 TCP socket 对端地址。部署在 Nginx、Cloudflare 等反向代理后面时,通过 `omni.WithTrustedProxies("127.0.0.1/32")` 或 `GO_OMNI_TRUSTED_PROXIES` 配置可信代理网段,框架才会从 `X-Forwarded-For` 右向左校验并取得真实客户端地址;未配置时转发头不会被信任。
|
||||
|
||||
这个设计借鉴了 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`。
|
||||
|
||||
native 当前只宣告 TLS ALPN `http/1.1`。未配置 `NextProtos` 时会自动补上;如果配置了 `h2` 等未实现协议,服务会在启动时直接返回错误,避免 TLS 协商成功后再被 HTTP/1.1 解析器拒绝。需要 HTTP/2 时,先让 Nginx/Cloudflare 在边缘终止 HTTP/2,再以 HTTP/1.1 回源;或继续使用默认的 `transport/nethttp` 适配器。native 的 HTTP/2 会在独立协议设计和压测完成后再进入。
|
||||
|
||||
HTTP/2 的进入条件、连接/流生命周期、背压和长测矩阵见 [native HTTP/2 进入条件](native/HTTP2.md)。
|
||||
|
||||
## 可移植路由规则(v0.3.0)
|
||||
|
||||
| 项目 | 约定 |
|
||||
| --- | --- |
|
||||
| 路径 | 顶层路由必须以 `/` 开头;区分大小写。禁止重复斜杠、`.` / `..` 段、查询串和片段标识。 |
|
||||
| 参数 | `/users/:id` 匹配一个非空路径段;参数必须占据整个段,同一路由内名称唯一。 |
|
||||
| 通配 | `/files/*path` 仅允许出现在末尾,值包含开头的 `/`;`/files/` 对应 `/`,`/files` 不匹配。 |
|
||||
| 路由优先级 | 相同方法内,从左向右比较,静态段优先于参数段;更具体的分支匹配失败后允许回退。 |
|
||||
| 注册冲突 | 同方法重复注册、相同前缀处参数名冲突、catch-all 与同级分支冲突,均在注册时 panic。不同方法可用同一路径。 |
|
||||
| 分组 | 支持嵌套与相对路径。`Group("/api").Handle("GET", "", h)` 注册 `/api`,传 `"/"` 则注册 `/api/`。 |
|
||||
| 尾部斜杠 | `/users` 与 `/users/` 是不同路由;不自动重定向、补斜杠或清理请求路径。 |
|
||||
| URL 编码 | 使用已解码的 `URL.Path`;例如 `%2F` 作为路径分隔符,`+` 保留为加号。 |
|
||||
| 方法 | `Handle` 接受大写字母组成的方法名;HEAD 不自动复用 GET,OPTIONS 不自动成功。 |
|
||||
| Any | 注册 GET、POST、PUT、PATCH、HEAD、OPTIONS、DELETE、CONNECT、TRACE 九种方法;其他方法需显式注册。 |
|
||||
| 404 / 405 | 未找到路径返回纯文本 404;路径存在但方法不支持时返回纯文本 405,`Allow` 去重并按字母排序。 |
|
||||
| 并发 | 启动前完成路由注册;允许并发处理请求,各请求参数、业务值、requestId 必须隔离。 |
|
||||
|
||||
HTTP 回退响应属于路由层,不进入应用路由中间件,不携带统一 JSON 封装或 requestId。业务 Handler 返回的错误则经过统一 JSON 错误处理。
|
||||
|
||||
业务层通过 `application.Handle("HEAD", path, handler)` 或 `group.Handle("OPTIONS", path, handler)` 显式注册其他方法,同样经过框架中间件和错误处理。
|
||||
|
||||
## 业务行为
|
||||
|
||||
- 同一套 Handler、Service、中间件用于两种适配器;应用、分组、嵌套分组、路由中间件依次进入、反序退出。
|
||||
- `Use` 只影响后续注册的路由。鉴权失败短路处理链,panic 转为统一 500。
|
||||
- URI / Query / JSON 绑定、业务错误、requestId 与业务日志关联一致。
|
||||
- `JSON` / `OK` 等方法先编码再发送;编码失败返回统一 500。已通过 Context 提交响应后,后续错误、panic 或重复响应不会追加 JSON。
|
||||
- 客户端断开会取消传入 Service 的 context;适配时保留原有 context 的值、截止时间和取消信号。
|
||||
- `Raw()` 是高级逃生口;直接写入、流式响应、Hijack 等行为不属于上述 Context 响应追踪契约,勿与自动响应方法混用。
|
||||
|
||||
## 自研适配器验收
|
||||
|
||||
实现 `httpx.Router` / `httpx.RouterGroup`,在调用 Handler 前通过 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern` 注入请求元数据。使用外部测试包接入公共验收套件:
|
||||
|
||||
```go
|
||||
package myrouter_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/transporttest"
|
||||
"example.com/project/myrouter"
|
||||
)
|
||||
|
||||
func TestContract(t *testing.T) {
|
||||
transporttest.Run(t, func() httpx.Router { return myrouter.New() })
|
||||
}
|
||||
```
|
||||
|
||||
工厂需每次返回新的空路由器。套件依次创建路由器,内部会发送并发请求、打开本地 HTTP 测试服务,运行时应启用 race 检查:
|
||||
|
||||
```sh
|
||||
go test -race ./transport/... ./examples/reference
|
||||
python3 scripts/verifyTransports.py --version v0.6.0-preview
|
||||
```
|
||||
|
||||
第一条验证标准库适配器复用完整 Handler / Service / Repository。第二条在源码目录外创建无 replace 的消费模块,从远程版本导入同一套验收包;自研适配器也应使用同一个公共测试包。
|
||||
|
||||
当前替换边界分成两层:`transport/nethttp` 提供标准库路由,`transport/native` 进一步接管 TCP 监听、连接管理和 HTTP/1.1 协议 I/O。`App.Run` / `App.RunContext` 仍使用标准库 `net/http.Server`,需要显式调用 `App.RunWith(ctx, native.New(nil), address)` 才会启用 native 预览实现。native 当前通过标准 `http.Request` / `http.ResponseWriter` 桥接到业务层,后续可在不改 Handler / Service 的前提下逐步替换解析器和响应写回。
|
||||
|
||||
125
transport/benchmarkTCP_test.go
Normal file
125
transport/benchmarkTCP_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
48
transport/benchmark_test.go
Normal file
48
transport/benchmark_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
)
|
||||
|
||||
func BenchmarkTransportRouteDispatch(b *testing.B) {
|
||||
b.Run("nethttp", func(b *testing.B) {
|
||||
router := netrouter.New()
|
||||
router.Handle(http.MethodGet, "/users/:id", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
request := httptest.NewRequest(http.MethodGet, "/users/42", nil)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
writer := httptest.NewRecorder()
|
||||
router.ServeHTTP(writer, request)
|
||||
if writer.Code != http.StatusNoContent {
|
||||
b.Fatalf("unexpected status %d", writer.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTransportApplicationRequest(b *testing.B) {
|
||||
b.Run("nethttp", func(b *testing.B) {
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
application.GET("/health", func(c *omni.Context) error {
|
||||
return c.OK(map[string]string{"status": "ok"})
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
writer := httptest.NewRecorder()
|
||||
application.ServeHTTP(writer, request)
|
||||
if writer.Code != http.StatusOK {
|
||||
b.Fatalf("unexpected status %d", writer.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
15
transport/contract_test.go
Normal file
15
transport/contract_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/transporttest"
|
||||
)
|
||||
|
||||
func TestAdapterContract(t *testing.T) {
|
||||
t.Run("nethttp", func(t *testing.T) {
|
||||
transporttest.Run(t, func() httpx.Router { return netrouter.New() })
|
||||
})
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Package gin adapts Gin to GoOmni's transport-neutral router contract.
|
||||
// Business packages should import httpx and app, not this package.
|
||||
package gin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
ginlib "github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Router is the default Gin-backed GoOmni router.
|
||||
type Router struct {
|
||||
engine *ginlib.Engine
|
||||
}
|
||||
|
||||
// New creates a Gin router without Gin's default logger or recovery middleware.
|
||||
func New(mode string) *Router {
|
||||
if mode != "" {
|
||||
ginlib.SetMode(mode)
|
||||
}
|
||||
return &Router{engine: ginlib.New()}
|
||||
}
|
||||
|
||||
// NewWithEngine wraps an existing Gin engine for incremental migration.
|
||||
func NewWithEngine(engine *ginlib.Engine) *Router { return &Router{engine: engine} }
|
||||
|
||||
// Engine exposes the underlying Gin engine to adapter-specific integrations.
|
||||
func (r *Router) Engine() *ginlib.Engine { return r.engine }
|
||||
|
||||
func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
r.engine.ServeHTTP(writer, request)
|
||||
}
|
||||
|
||||
func (r *Router) Handle(method, path string, handler http.Handler) {
|
||||
r.engine.Handle(method, path, r.adapt(handler))
|
||||
}
|
||||
|
||||
func (r *Router) Any(path string, handler http.Handler) { r.engine.Any(path, r.adapt(handler)) }
|
||||
|
||||
func (r *Router) Group(path string) httpx.RouterGroup {
|
||||
return &group{group: r.engine.Group(path)}
|
||||
}
|
||||
|
||||
type group struct{ group *ginlib.RouterGroup }
|
||||
|
||||
func (g *group) Handle(method, path string, handler http.Handler) {
|
||||
g.group.Handle(method, path, adapt(handler))
|
||||
}
|
||||
|
||||
func (g *group) Any(path string, handler http.Handler) { g.group.Any(path, adapt(handler)) }
|
||||
|
||||
func (g *group) Group(path string) httpx.RouterGroup {
|
||||
return &group{group: g.group.Group(path)}
|
||||
}
|
||||
|
||||
func (r *Router) adapt(handler http.Handler) ginlib.HandlerFunc { return adapt(handler) }
|
||||
|
||||
func adapt(handler http.Handler) ginlib.HandlerFunc {
|
||||
return func(ctx *ginlib.Context) {
|
||||
params := make(map[string]string, len(ctx.Params))
|
||||
for _, param := range ctx.Params {
|
||||
params[param.Key] = param.Value
|
||||
}
|
||||
requestContext := httpx.WithRouteParams(ctx.Request.Context(), params)
|
||||
requestContext = httpx.WithRoutePattern(requestContext, ctx.FullPath())
|
||||
request := ctx.Request.WithContext(requestContext)
|
||||
handler.ServeHTTP(ctx.Writer, request)
|
||||
}
|
||||
}
|
||||
45
transport/http2/PROXY.md
Normal file
45
transport/http2/PROXY.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# HTTP/2 代理回源验收
|
||||
|
||||
本项验证的链路是:`Go HTTP/2 客户端 → TLS/h2 → Nginx → TLS/h2 → transport/http2`。客户端与回源两段分别检查协议、TLS 和请求内容;它使用真实 Nginx 进程及生成的测试证书。
|
||||
|
||||
Nginx 的 `proxy_http_version 2` 从 1.29.4 起可用,见 [官方配置说明](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_http_version)。本次固定使用 1.30.5。原有 `scripts/verifyProxy.py` 和 Nginx 1.27 环境继续用于 native HTTP/1.1 回源验收。
|
||||
|
||||
## 运行
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyHTTP2Proxy.py --count 3
|
||||
python3 scripts/verifyHTTP2Proxy.py --race --count 3 --output performance/http2ProxyRaceLinux.json
|
||||
```
|
||||
|
||||
需要指定 Go 模块代理时追加 `--goproxy https://goproxy.cn,direct`。普通模式使用 `nginx:1.30.5-alpine`,race 模式使用 `nginx:1.30.5`,以提供 Go race 二进制需要的 glibc 运行环境;使用 `--nginx-image` 覆盖时需自行保持该兼容性。
|
||||
|
||||
脚本先在 `golang:1.25` 中执行 vet 并编译测试二进制,清理编译容器后,再在 Nginx 镜像中执行测试。两个阶段顺序运行,每个容器固定限制为 2 核、2 GiB 内存、禁用 swap、256 个进程/线程。整个运行默认 600 秒总时限,单次测试进程还有 120 秒时限。
|
||||
|
||||
集成测试容器使用 `--network none`、只读根文件系统和 128 MiB 临时内存目录,没有发布端口。客户端、Nginx 和新旧两个后端都只监听容器内回环地址。超时、SIGTERM、SIGHUP 会清理当前运行拥有的容器;检测到其他 HTTP/2 验收容器运行时,脚本会拒绝开始。
|
||||
|
||||
## 验收内容
|
||||
|
||||
| 场景 | 必须满足的结果 |
|
||||
| --- | --- |
|
||||
| 双段 TLS/h2 | 客户端验证代理证书;代理验证源服务器证书和主机名。业务 handler 必须看到 HTTP/2、ALPN h2 和预期 SNI。 |
|
||||
| 回源连接复用 | 32 次顺序 POST 仅建立 1 条回源 TLS 连接;每次校验独立 requestId、方法、请求体长度与 SHA-256。 |
|
||||
| 证书失败 | 不可信 CA 和错误主机名分别得到 502,均未进入业务 handler;正常路由随后仍可请求成功。 |
|
||||
| 超时 | 代理 250 ms 读取超时得到 504,后端请求 context 被取消、活动请求回到 0,后续请求恢复。 |
|
||||
| 客户端取消 | 客户端取消后,后端 handler 能退出并释放请求槽位,后续请求恢复。 |
|
||||
| GOAWAY 与新旧实例切换 | 旧实例保留一个处理中的 POST;开始 Shutdown 后必须观察到 GOAWAY。随后 20 个独立 POST 进入备用的新实例,旧 POST 释放后仍返回原实例的正确响应。旧实例最终连接/请求归零,业务执行无重复。 |
|
||||
|
||||
新旧实例场景使用 Nginx primary/backup 上游与有限重试;测试没有开启 `non_idempotent` 重试。除了检查每次响应,还检查两个实例的实际 handler 次数及重复 requestId,避免重试掩盖重复执行。
|
||||
|
||||
本次 Nginx 在超时和取消场景中关闭了相关回源连接,随后重新建连恢复;这里验收的是请求取消与资源回收,不要求代理必须通过 RST_STREAM 保留同一条回源连接。
|
||||
|
||||
## 已有结果与边界
|
||||
|
||||
2026-09-23,Linux amd64、Go 1.25.14、Nginx 1.30.5:普通模式和 race 模式各连续 3 轮全部通过,共 18 个子场景,无数据竞争。两轮运行后原有 7 个业务容器仍在运行。
|
||||
|
||||
- [普通模式报告](../../performance/http2ProxyLinux.json) / [原始日志](../../performance/http2ProxyLinux.txt)
|
||||
- [race 报告](../../performance/http2ProxyRaceLinux.json) / [原始日志](../../performance/http2ProxyRaceLinux.txt)
|
||||
- [总时限、SIGTERM、SIGHUP 清理实测](../../performance/http2ProxyCleanupLinux.json)
|
||||
|
||||
报告记录测试提交、源码 SHA-256、镜像 ID、完整执行命令、结果与日志校验值。它是功能联调,不是代理吞吐基准;使用单进程 Nginx 测试配置,验证后端实例切换,不覆盖 Nginx master/worker 配置重载、公网链路或 Cloudflare 实网部署。`transport/http2` 仍是独立实验适配器,native 的 HTTP/1.1 默认路径保持现状。
|
||||
116
transport/http2/admission_test.go
Normal file
116
transport/http2/admission_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func TestTLSConnectionLimitRecoversAfterHandshakeTimeout(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
var handled atomic.Int32
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
handled.Add(1)
|
||||
_, _ = io.WriteString(writer, "ok")
|
||||
}))
|
||||
server.MaxConnections = 1
|
||||
server.TLSHandshakeTimeout = time.Second
|
||||
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,
|
||||
})
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Occupy the only connection slot without sending a TLS ClientHello.
|
||||
held, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 1 })
|
||||
|
||||
rejected, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rejected.Close()
|
||||
if err := rejected.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buffer [1]byte
|
||||
if _, err := rejected.Read(buffer[:]); err == nil {
|
||||
t.Fatal("connection above the cap was not closed")
|
||||
} else if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
|
||||
t.Fatal("connection above the cap was not rejected promptly")
|
||||
}
|
||||
if stats := server.Stats(); stats.RejectedConnections != 1 || handled.Load() != 0 {
|
||||
t.Fatalf("unexpected admission state: %+v handled=%d", stats, handled.Load())
|
||||
}
|
||||
|
||||
// The handshake deadline, rather than a client disconnect, must release it.
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 0 })
|
||||
if err := held.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := held.Read(buffer[:]); err == nil {
|
||||
t.Fatal("timed-out TLS connection remained open")
|
||||
} else if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
|
||||
t.Fatal("timed-out TLS connection was not closed")
|
||||
}
|
||||
|
||||
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
}}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
response, err := client.Get("https://" + listener.Addr().String() + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || string(body) != "ok" {
|
||||
t.Fatalf("TLS admission did not recover: status=%d protocol=%s body=%q err=%v", response.StatusCode, response.Proto, body, readErr)
|
||||
}
|
||||
transport.CloseIdleConnections()
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 0 && stats.ActiveRequests == 0 })
|
||||
if stats := server.Stats(); stats.AcceptedConnections != 3 || stats.RejectedConnections != 1 || stats.ProtocolErrors != 0 || handled.Load() != 1 {
|
||||
t.Fatalf("unexpected recovered admission state: %+v handled=%d", stats, handled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func waitHTTP2Stats(t *testing.T, server *Server, predicate func(Stats) bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if predicate(server.Stats()) {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("HTTP/2 stats did not reach expected state: %+v", server.Stats())
|
||||
}
|
||||
274
transport/http2/flowControl_test.go
Normal file
274
transport/http2/flowControl_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/hpack"
|
||||
)
|
||||
|
||||
// A zero receive window simulates a client that has stopped consuming DATA.
|
||||
// Headers need no flow-control credit, so a 204 on another stream should still
|
||||
// complete while the large response waits for a WINDOW_UPDATE or cancellation.
|
||||
func TestSlowResponseStreamIsolationAndRecovery(t *testing.T) {
|
||||
payload := strings.Repeat("x", 32<<10)
|
||||
started := make(chan struct{}, 2)
|
||||
written := make(chan error, 2)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/health" {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if request.URL.Path == "/ready" {
|
||||
_, _ = io.WriteString(writer, "ready")
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
writer.(http.Flusher).Flush()
|
||||
started <- struct{}{}
|
||||
_, err := io.WriteString(writer, payload)
|
||||
written <- err
|
||||
}))
|
||||
server.MaxConnections = 2
|
||||
server.MaxConcurrentRequests = 2
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
|
||||
slow := newFramePeer(t, address, xhttp2.Setting{ID: xhttp2.SettingInitialWindowSize, Val: 0})
|
||||
defer slow.Close()
|
||||
slow.framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
|
||||
slow.get(t, 1, "/large")
|
||||
waitSignal(t, started)
|
||||
|
||||
// The same TCP connection can still carry response headers for other streams.
|
||||
slow.get(t, 3, "/health")
|
||||
readNoContent(t, slow, 3)
|
||||
select {
|
||||
case err := <-written:
|
||||
t.Fatalf("large response escaped the zero stream window: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
// A second connection also remains usable while the first handler is blocked.
|
||||
fast := newFramePeer(t, address)
|
||||
defer fast.Close()
|
||||
fast.framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
|
||||
fast.get(t, 1, "/ready")
|
||||
if body := fast.body(t, 1); body != "ready" {
|
||||
t.Fatalf("other connection response: %q", body)
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveConnections != 2 || stats.RejectedRequests != 0 {
|
||||
t.Fatalf("slow response affected other traffic: %+v", stats)
|
||||
}
|
||||
|
||||
// Restoring stream credit must resume the existing response without reconnecting.
|
||||
if err := slow.framer.WriteWindowUpdate(1, uint32(len(payload))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body := slow.body(t, 1); body != payload {
|
||||
t.Fatalf("resumed response mismatch: got %d bytes, want %d", len(body), len(payload))
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err != nil {
|
||||
t.Fatalf("resumed write: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("resumed write did not return")
|
||||
}
|
||||
|
||||
// A new zero-window stream must release its handler when the client cancels.
|
||||
slow.get(t, 5, "/large")
|
||||
waitSignal(t, started)
|
||||
if err := slow.framer.WriteRSTStream(5, xhttp2.ErrCodeCancel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err == nil {
|
||||
t.Fatal("canceled zero-window write unexpectedly succeeded")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("RST_STREAM did not unblock the writer")
|
||||
}
|
||||
|
||||
slow.get(t, 7, "/health")
|
||||
readNoContent(t, slow, 7)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for server.Stats().ActiveRequests != 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveRequests != 0 || stats.RSTStreams != 1 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 {
|
||||
t.Fatalf("flow control did not recover: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
// Leave stream credit available while exhausting the shared connection window.
|
||||
// DATA must pause, but headers and traffic on a different TCP connection must
|
||||
// remain usable until connection-level credit is restored.
|
||||
func TestConnectionWindowIsolationAndRecovery(t *testing.T) {
|
||||
const initialConnectionWindow = 65535
|
||||
payload := strings.Repeat("x", 96<<10)
|
||||
written := make(chan error, 1)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/health":
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
case "/ready":
|
||||
_, _ = io.WriteString(writer, "ready")
|
||||
default:
|
||||
writer.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
writer.(http.Flusher).Flush()
|
||||
_, err := io.WriteString(writer, payload)
|
||||
written <- err
|
||||
}
|
||||
}))
|
||||
server.MaxConnections = 2
|
||||
server.MaxConcurrentRequests = 2
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
|
||||
slow := newFramePeer(t, address, xhttp2.Setting{ID: xhttp2.SettingInitialWindowSize, Val: 128 << 10})
|
||||
defer slow.Close()
|
||||
slow.framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
|
||||
slow.get(t, 1, "/large")
|
||||
var received strings.Builder
|
||||
for received.Len() < initialConnectionWindow {
|
||||
switch frame := slow.read(t).(type) {
|
||||
case *xhttp2.DataFrame:
|
||||
if frame.StreamID != 1 || frame.StreamEnded() {
|
||||
t.Fatalf("unexpected DATA before connection credit was restored: %+v", frame)
|
||||
}
|
||||
received.Write(frame.Data())
|
||||
case *xhttp2.RSTStreamFrame:
|
||||
t.Fatalf("unexpected server reset: %+v", frame)
|
||||
}
|
||||
}
|
||||
if received.Len() != initialConnectionWindow {
|
||||
t.Fatalf("server exceeded connection window: got %d bytes", received.Len())
|
||||
}
|
||||
|
||||
slow.get(t, 3, "/health")
|
||||
readNoContent(t, slow, 3)
|
||||
select {
|
||||
case err := <-written:
|
||||
t.Fatalf("large response escaped exhausted connection window: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
fast := newFramePeer(t, address)
|
||||
defer fast.Close()
|
||||
fast.get(t, 1, "/ready")
|
||||
if body := fast.body(t, 1); body != "ready" {
|
||||
t.Fatalf("other connection response: %q", body)
|
||||
}
|
||||
|
||||
remaining := len(payload) - received.Len()
|
||||
if err := slow.framer.WriteWindowUpdate(0, uint32(remaining)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
received.WriteString(slow.body(t, 1))
|
||||
if received.String() != payload {
|
||||
t.Fatalf("resumed connection response mismatch: got %d bytes, want %d", received.Len(), len(payload))
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err != nil {
|
||||
t.Fatalf("resumed connection write: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("connection WINDOW_UPDATE did not unblock writer")
|
||||
}
|
||||
|
||||
if err := slow.framer.WriteWindowUpdate(0, uint32(len("ready"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slow.get(t, 5, "/ready")
|
||||
if body := slow.body(t, 5); body != "ready" {
|
||||
t.Fatalf("reused connection response: %q", body)
|
||||
}
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveRequests == 0 })
|
||||
stats := server.Stats()
|
||||
if stats.AcceptedConnections != 2 || stats.ActiveConnections != 2 || stats.RejectedConnections != 0 || stats.RejectedRequests != 0 || stats.ProtocolErrors != 0 || stats.RSTStreams != 0 {
|
||||
t.Fatalf("connection flow control did not recover: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownReleasesFlowControlledWriter(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
written := make(chan error, 1)
|
||||
requestContext := make(chan context.Context, 1)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestContext <- request.Context()
|
||||
writer.(http.Flusher).Flush()
|
||||
close(started)
|
||||
_, err := io.WriteString(writer, strings.Repeat("x", 32<<10))
|
||||
written <- err
|
||||
}))
|
||||
address, done := launchServer(t, server, context.Background())
|
||||
peer := newFramePeer(t, address, xhttp2.Setting{ID: xhttp2.SettingInitialWindowSize, Val: 0})
|
||||
defer peer.Close()
|
||||
peer.get(t, 1, "/blocked")
|
||||
waitSignal(t, started)
|
||||
requestCtx := <-requestContext
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
stopped := make(chan error, 1)
|
||||
go func() { stopped <- server.Shutdown(ctx) }()
|
||||
for {
|
||||
if frame, ok := peer.read(t).(*xhttp2.GoAwayFrame); ok {
|
||||
if frame.ErrCode != xhttp2.ErrCodeNo || frame.LastStreamID != 1 {
|
||||
t.Fatalf("unexpected GOAWAY for blocked writer: %+v", frame)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := serveResult(t, stopped); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("shutdown with exhausted receive window: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err == nil {
|
||||
t.Fatal("blocked write unexpectedly succeeded without window credit")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("shutdown deadline did not release flow-controlled writer")
|
||||
}
|
||||
if requestCtx.Err() == nil {
|
||||
t.Fatal("forced shutdown did not cancel request context")
|
||||
}
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveRequests == 0 && stats.ActiveConnections == 0 })
|
||||
if stats := server.Stats(); stats.GoAwayFrames != 1 || stats.ProtocolErrors != 0 {
|
||||
t.Fatalf("blocked connection did not close cleanly: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func readNoContent(t *testing.T, peer *framePeer, streamID uint32) {
|
||||
t.Helper()
|
||||
for {
|
||||
switch frame := peer.read(t).(type) {
|
||||
case *xhttp2.MetaHeadersFrame:
|
||||
if frame.StreamID != streamID {
|
||||
continue
|
||||
}
|
||||
if frame.PseudoValue("status") != "204" || !frame.StreamEnded() {
|
||||
t.Fatalf("stream %d: expected complete 204, got %+v", streamID, frame)
|
||||
}
|
||||
return
|
||||
case *xhttp2.DataFrame:
|
||||
t.Fatalf("unexpected DATA while receive window is exhausted on stream %d", frame.StreamID)
|
||||
case *xhttp2.RSTStreamFrame:
|
||||
t.Fatalf("unexpected server reset: %+v", frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
106
transport/http2/frameObserver.go
Normal file
106
transport/http2/frameObserver.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
frameTypeRSTStream byte = 0x3
|
||||
frameTypeGoAway byte = 0x7
|
||||
)
|
||||
|
||||
type frameDirection uint8
|
||||
|
||||
const (
|
||||
frameRead frameDirection = iota
|
||||
frameWrite
|
||||
)
|
||||
|
||||
// observedConn keeps the HTTP/2 implementation opaque while exposing the
|
||||
// small amount of frame-level telemetry that x/net/http2 does not provide.
|
||||
// It parses only the nine-byte frame header and skips payload bytes without
|
||||
// buffering them, so large DATA frames cannot increase telemetry memory.
|
||||
type observedConn struct {
|
||||
net.Conn
|
||||
onFrame func(frameDirection, byte)
|
||||
readParser frameParser
|
||||
writeParser frameParser
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
func (c *observedConn) Read(buffer []byte) (int, error) {
|
||||
n, err := c.Conn.Read(buffer)
|
||||
if n > 0 {
|
||||
c.readParser.observe(buffer[:n], frameRead, c.onFrame)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *observedConn) Write(buffer []byte) (int, error) {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
n, err := c.Conn.Write(buffer)
|
||||
if n > 0 {
|
||||
c.writeParser.observe(buffer[:n], frameWrite, c.onFrame)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// observedTLSConn preserves the connection state hook used by x/net/http2 so
|
||||
// handlers still receive Request.TLS after frame observation is added.
|
||||
type observedTLSConn struct {
|
||||
*observedConn
|
||||
state tls.ConnectionState
|
||||
}
|
||||
|
||||
func (c *observedTLSConn) ConnectionState() tls.ConnectionState { return c.state }
|
||||
|
||||
type frameParser struct {
|
||||
prefaceRemain int
|
||||
header [9]byte
|
||||
headerBytes int
|
||||
payloadRemain int
|
||||
frameType byte
|
||||
}
|
||||
|
||||
func (p *frameParser) observe(buffer []byte, direction frameDirection, onFrame func(frameDirection, byte)) {
|
||||
for len(buffer) > 0 {
|
||||
if p.prefaceRemain > 0 {
|
||||
skip := p.prefaceRemain
|
||||
if skip > len(buffer) {
|
||||
skip = len(buffer)
|
||||
}
|
||||
p.prefaceRemain -= skip
|
||||
buffer = buffer[skip:]
|
||||
continue
|
||||
}
|
||||
if p.payloadRemain > 0 {
|
||||
skip := p.payloadRemain
|
||||
if skip > len(buffer) {
|
||||
skip = len(buffer)
|
||||
}
|
||||
p.payloadRemain -= skip
|
||||
buffer = buffer[skip:]
|
||||
if p.payloadRemain > 0 {
|
||||
continue
|
||||
}
|
||||
onFrame(direction, p.frameType)
|
||||
continue
|
||||
}
|
||||
|
||||
copied := copy(p.header[p.headerBytes:], buffer)
|
||||
p.headerBytes += copied
|
||||
buffer = buffer[copied:]
|
||||
if p.headerBytes < len(p.header) {
|
||||
continue
|
||||
}
|
||||
p.payloadRemain = int(p.header[0])<<16 | int(p.header[1])<<8 | int(p.header[2])
|
||||
p.frameType = p.header[3]
|
||||
p.headerBytes = 0
|
||||
if p.payloadRemain == 0 {
|
||||
onFrame(direction, p.frameType)
|
||||
}
|
||||
}
|
||||
}
|
||||
249
transport/http2/lifecycle_test.go
Normal file
249
transport/http2/lifecycle_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/hpack"
|
||||
)
|
||||
|
||||
// A raw peer makes GOAWAY and RST_STREAM observable without client retries.
|
||||
type framePeer struct {
|
||||
net.Conn
|
||||
framer *xhttp2.Framer
|
||||
}
|
||||
|
||||
func newFramePeer(t *testing.T, address string, settings ...xhttp2.Setting) *framePeer {
|
||||
t.Helper()
|
||||
c, err := net.DialTimeout("tcp", address, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
p := &framePeer{c, xhttp2.NewFramer(c, c)}
|
||||
if _, err = io.WriteString(c, xhttp2.ClientPreface); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = p.framer.WriteSettings(settings...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (p *framePeer) get(t *testing.T, id uint32, path string) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
e := hpack.NewEncoder(&b)
|
||||
for _, field := range []hpack.HeaderField{{Name: ":method", Value: "GET"}, {Name: ":scheme", Value: "http"}, {Name: ":authority", Value: "localhost"}, {Name: ":path", Value: path}} {
|
||||
if err := e.WriteField(field); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := p.framer.WriteHeaders(xhttp2.HeadersFrameParam{StreamID: id, EndStream: true, EndHeaders: true, BlockFragment: b.Bytes()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func (p *framePeer) read(t *testing.T) xhttp2.Frame {
|
||||
t.Helper()
|
||||
f, err := p.framer.ReadFrame()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, ok := f.(*xhttp2.SettingsFrame); ok && !s.IsAck() {
|
||||
if err := p.framer.WriteSettingsAck(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
func (p *framePeer) body(t *testing.T, id uint32) string {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
for {
|
||||
switch f := p.read(t).(type) {
|
||||
case *xhttp2.DataFrame:
|
||||
if f.StreamID != id {
|
||||
t.Fatalf("unexpected stream %d", f.StreamID)
|
||||
}
|
||||
b.Write(f.Data())
|
||||
if f.StreamEnded() {
|
||||
return b.String()
|
||||
}
|
||||
case *xhttp2.HeadersFrame:
|
||||
if f.StreamID == id && f.StreamEnded() {
|
||||
return b.String()
|
||||
}
|
||||
case *xhttp2.RSTStreamFrame:
|
||||
t.Fatalf("unexpected RST_STREAM: %v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
func waitSignal(t *testing.T, signal <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-signal:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for event")
|
||||
}
|
||||
}
|
||||
func launchServer(t *testing.T, s *Server, ctx context.Context) (string, <-chan error) {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.Serve(ctx, l) }()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(ctx); err != nil {
|
||||
t.Errorf("cleanup shutdown: %v", err)
|
||||
}
|
||||
l.Close()
|
||||
})
|
||||
return l.Addr().String(), done
|
||||
}
|
||||
func serveResult(t *testing.T, done <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not finish")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func TestShutdownSendsGoAwayAndDrains(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
requestCtx := make(chan context.Context, 1)
|
||||
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/hold" {
|
||||
t.Error("new stream reached handler during drain")
|
||||
return
|
||||
}
|
||||
requestCtx <- r.Context()
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
io.WriteString(w, "completed")
|
||||
}))
|
||||
address, done := launchServer(t, s, context.Background())
|
||||
p := newFramePeer(t, address)
|
||||
p.get(t, 1, "/hold")
|
||||
waitSignal(t, started)
|
||||
rctx := <-requestCtx
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
stopped := make(chan error, 1)
|
||||
go func() { stopped <- s.Shutdown(ctx) }()
|
||||
for {
|
||||
if f, ok := p.read(t).(*xhttp2.GoAwayFrame); ok {
|
||||
if f.ErrCode != xhttp2.ErrCodeNo || f.LastStreamID != 1 {
|
||||
t.Fatalf("GOAWAY=%+v", f)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := rctx.Err(); err != nil {
|
||||
t.Fatalf("graceful shutdown canceled active request: %v", err)
|
||||
}
|
||||
if stats := s.Stats(); stats.GoAwayFrames != 1 {
|
||||
t.Fatalf("expected one observed GOAWAY frame, got %+v", stats)
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("Serve returned before drain: %v", err)
|
||||
default:
|
||||
}
|
||||
p.get(t, 3, "/late")
|
||||
release <- struct{}{}
|
||||
if got := p.body(t, 1); got != "completed" {
|
||||
t.Fatalf("body=%q", got)
|
||||
}
|
||||
p.Close()
|
||||
if err := serveResult(t, stopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats := s.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.GoAwaySent != 1 {
|
||||
t.Fatalf("not drained: %+v", stats)
|
||||
}
|
||||
}
|
||||
func TestRSTStreamCancelsOnlyOneRequest(t *testing.T) {
|
||||
started := make(chan struct{}, 2)
|
||||
canceled := make(chan string, 2)
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started <- struct{}{}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
canceled <- r.URL.Path
|
||||
return
|
||||
case <-release:
|
||||
io.WriteString(w, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
s.MaxConcurrentRequests = 2
|
||||
address, _ := launchServer(t, s, context.Background())
|
||||
p := newFramePeer(t, address)
|
||||
p.get(t, 1, "/cancel")
|
||||
p.get(t, 3, "/survive")
|
||||
waitSignal(t, started)
|
||||
waitSignal(t, started)
|
||||
if err := p.framer.WriteRSTStream(1, xhttp2.ErrCodeCancel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case path := <-canceled:
|
||||
if path != "/cancel" {
|
||||
t.Fatalf("canceled %s", path)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("RST did not cancel request")
|
||||
}
|
||||
if stats := s.Stats(); stats.RSTStreams != 1 {
|
||||
t.Fatalf("expected one observed RST_STREAM, got %+v", stats)
|
||||
}
|
||||
release <- struct{}{}
|
||||
if got := p.body(t, 3); got != "/survive" {
|
||||
t.Fatalf("sibling body=%q", got)
|
||||
}
|
||||
p.get(t, 5, "/reused")
|
||||
waitSignal(t, started)
|
||||
release <- struct{}{}
|
||||
if got := p.body(t, 5); got != "/reused" {
|
||||
t.Fatalf("connection reuse body=%q", got)
|
||||
}
|
||||
}
|
||||
func TestCanceledServeContextWaitsForShutdownDeadline(t *testing.T) {
|
||||
started, canceled := make(chan struct{}), make(chan struct{})
|
||||
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-r.Context().Done(); close(canceled) }))
|
||||
s.ShutdownTimeout = 100 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
address, done := launchServer(t, s, ctx)
|
||||
p := newFramePeer(t, address)
|
||||
p.get(t, 1, "/hold")
|
||||
waitSignal(t, started)
|
||||
cancel()
|
||||
if err := serveResult(t, done); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Serve should report drain timeout, got %v", err)
|
||||
}
|
||||
waitSignal(t, canceled)
|
||||
}
|
||||
304
transport/http2/mixedSoak_test.go
Normal file
304
transport/http2/mixedSoak_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
type mixedConnectionCounts struct {
|
||||
health atomic.Uint64
|
||||
slowUploads atomic.Uint64
|
||||
slowDownloads atomic.Uint64
|
||||
}
|
||||
|
||||
// TestTLSMixedSoak shares each of eight connections between fourteen fast
|
||||
// workers, one paced uploader and one paced downloader. The 8 MiB download is
|
||||
// larger than x/net v0.25.0's 4 MiB initial stream window, requiring window
|
||||
// updates to finish. This does not exhaust the connection-level receive window.
|
||||
func TestTLSMixedSoak(t *testing.T) {
|
||||
const connectionCount, workersPerConnection = 8, 16
|
||||
const uploadBytes, downloadBytes = 256 << 10, 8 << 20
|
||||
started := time.Now()
|
||||
duration := soakDuration(t)
|
||||
logSoakResources(t, started, "before", 0, 0, Stats{})
|
||||
t.Logf("scenario=mixed connections=%d workersPerConnection=%d uploadBytes=%d uploadChunkBytes=4096 uploadDelay=2ms downloadBytes=%d downloadChunkBytes=65536 downloadDelay=20ms",
|
||||
connectionCount, workersPerConnection, uploadBytes, downloadBytes)
|
||||
downloadPayload := bytes.Repeat([]byte("x"), downloadBytes)
|
||||
expectedDownload := sha256.Sum256(downloadPayload)
|
||||
expectedUpload := sha256.Sum256(downloadPayload[:uploadBytes])
|
||||
certificate, roots := testCertificate(t)
|
||||
var serverFailures atomic.Uint64
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("X-Soak-Request-Id", request.Header.Get("X-Soak-Request-Id"))
|
||||
switch request.URL.Path {
|
||||
case "/slowUpload":
|
||||
digest := sha256.New()
|
||||
count, err := io.Copy(digest, request.Body)
|
||||
if err != nil || count != uploadBytes || !bytes.Equal(digest.Sum(nil), expectedUpload[:]) {
|
||||
serverFailures.Add(1)
|
||||
http.Error(writer, "incomplete upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(writer, "uploaded")
|
||||
case "/slowDownload":
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(downloadBytes))
|
||||
if n, err := writer.Write(downloadPayload); err != nil || n != downloadBytes {
|
||||
serverFailures.Add(1)
|
||||
}
|
||||
default:
|
||||
_, _ = io.WriteString(writer, "healthy")
|
||||
}
|
||||
}))
|
||||
server.MaxConnections = connectionCount
|
||||
server.MaxConcurrentRequests = connectionCount * workersPerConnection
|
||||
server.MaxConcurrentStreams = workersPerConnection
|
||||
server.MaxBodyBytes = 1 << 20
|
||||
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,
|
||||
})
|
||||
}()
|
||||
var transports []*xhttp2.Transport
|
||||
var clients []*http.Client
|
||||
for index := 0; index < connectionCount; index++ {
|
||||
transport := &xhttp2.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12, NextProtos: []string{"h2"}},
|
||||
StrictMaxConcurrentStreams: true,
|
||||
}
|
||||
transports = append(transports, transport)
|
||||
clients = append(clients, &http.Client{Transport: transport, Timeout: 20 * time.Second})
|
||||
}
|
||||
var stopOnce sync.Once
|
||||
stop := func() {
|
||||
stopOnce.Do(func() {
|
||||
for _, transport := range transports {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
t.Errorf("mixed soak shutdown: %v", err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Errorf("mixed soak serve: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
defer stop()
|
||||
|
||||
var counts [connectionCount]mixedConnectionCounts
|
||||
var requests, failures atomic.Uint64
|
||||
var failureOnce sync.Once
|
||||
var firstFailure string
|
||||
recordFailure := func(err error) {
|
||||
failures.Add(1)
|
||||
failureOnce.Do(func() { firstFailure = err.Error() })
|
||||
}
|
||||
logCounts := func(phase string) {
|
||||
var health, uploads, downloads uint64
|
||||
for i := range counts {
|
||||
health += counts[i].health.Load()
|
||||
uploads += counts[i].slowUploads.Load()
|
||||
downloads += counts[i].slowDownloads.Load()
|
||||
}
|
||||
t.Logf("mixedTotals phase=%s health=%d slowUploads=%d slowDownloads=%d uploadedBytes=%d downloadedBytes=%d serverFailures=%d",
|
||||
phase, health, uploads, downloads, uploads*uploadBytes, downloads*downloadBytes, serverFailures.Load())
|
||||
}
|
||||
monitorStop, monitorDone := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(monitorDone)
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
logSoakResources(t, started, "running", requests.Load(), failures.Load(), server.Stats())
|
||||
logCounts("running")
|
||||
case <-monitorStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
var monitorOnce sync.Once
|
||||
stopMonitor := func() { monitorOnce.Do(func() { close(monitorStop); <-monitorDone }) }
|
||||
defer stopMonitor()
|
||||
|
||||
deadline := time.Now().Add(duration)
|
||||
var workers sync.WaitGroup
|
||||
for worker := 0; worker < connectionCount*workersPerConnection; worker++ {
|
||||
workers.Add(1)
|
||||
go func(worker int) {
|
||||
defer workers.Done()
|
||||
connection := worker / workersPerConnection
|
||||
kind := worker % workersPerConnection
|
||||
for sequence := uint64(0); time.Now().Before(deadline) && failures.Load() == 0; sequence++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
path, method := "/health", http.MethodGet
|
||||
var body io.Reader
|
||||
if kind == 0 {
|
||||
path, method = "/slowUpload", http.MethodPost
|
||||
body = &pacedUpload{ctx: ctx, remaining: uploadBytes}
|
||||
} else if kind == 1 {
|
||||
path = "/slowDownload"
|
||||
}
|
||||
requestID := strconv.Itoa(worker) + ":" + strconv.FormatUint(sequence, 10)
|
||||
request, err := http.NewRequestWithContext(ctx, method, "https://"+listener.Addr().String()+path, body)
|
||||
if err == nil {
|
||||
request.Header.Set("X-Soak-Request-Id", requestID)
|
||||
err = checkMixedResponse(ctx, clients[connection], request, kind, downloadBytes, expectedDownload)
|
||||
}
|
||||
cancel()
|
||||
if err != nil {
|
||||
recordFailure(fmt.Errorf("connection=%d path=%s: %w", connection, path, err))
|
||||
continue
|
||||
}
|
||||
requests.Add(1)
|
||||
switch kind {
|
||||
case 0:
|
||||
counts[connection].slowUploads.Add(1)
|
||||
case 1:
|
||||
counts[connection].slowDownloads.Add(1)
|
||||
default:
|
||||
counts[connection].health.Add(1)
|
||||
}
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
workers.Wait()
|
||||
stopMonitor()
|
||||
stop()
|
||||
logSoakResources(t, started, "after", requests.Load(), failures.Load(), server.Stats())
|
||||
logCounts("after")
|
||||
var completed uint64
|
||||
for index := range counts {
|
||||
c := &counts[index]
|
||||
completed += c.health.Load() + c.slowUploads.Load() + c.slowDownloads.Load()
|
||||
t.Logf("connection=%d health=%d slowUploads=%d slowDownloads=%d", index, c.health.Load(), c.slowUploads.Load(), c.slowDownloads.Load())
|
||||
if c.health.Load() == 0 || c.slowUploads.Load() == 0 || c.slowDownloads.Load() == 0 {
|
||||
t.Errorf("connection %d did not complete all workload types", index)
|
||||
}
|
||||
}
|
||||
if completed != requests.Load() {
|
||||
t.Errorf("mixed workload accounting: completed=%d requests=%d", completed, requests.Load())
|
||||
}
|
||||
// Release workload fixtures and idle client pools before measuring retained
|
||||
// objects. Two GCs also allow sync.Pool's previous generation to be collected.
|
||||
downloadPayload, clients, transports = nil, nil, nil
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
logSoakResources(t, started, "afterReclaim", requests.Load(), failures.Load(), server.Stats())
|
||||
if failures.Load() != 0 || serverFailures.Load() != 0 {
|
||||
t.Fatalf("mixed soak failures=%d serverFailures=%d firstFailure=%q", failures.Load(), serverFailures.Load(), firstFailure)
|
||||
}
|
||||
if stats := server.Stats(); stats.AcceptedConnections != connectionCount || stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 || stats.RejectedConnections != 0 {
|
||||
t.Fatalf("mixed soak connections, errors or recovery: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func checkMixedResponse(ctx context.Context, client *http.Client, request *http.Request, kind, downloadBytes int, expectedHash [sha256.Size]byte) error {
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || response.TLS == nil || response.TLS.NegotiatedProtocol != "h2" || response.Header.Get("X-Soak-Request-Id") != request.Header.Get("X-Soak-Request-Id") {
|
||||
return fmt.Errorf("unexpected status, protocol or request ID: %s", response.Status)
|
||||
}
|
||||
if kind != 1 {
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||
expected := "healthy"
|
||||
if kind == 0 {
|
||||
expected = "uploaded"
|
||||
}
|
||||
if err != nil || string(body) != expected {
|
||||
return fmt.Errorf("invalid response %q: %v", body, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
digest := sha256.New()
|
||||
buffer := make([]byte, 64<<10)
|
||||
total := 0
|
||||
for {
|
||||
n, err := response.Body.Read(buffer)
|
||||
total += n
|
||||
if total > downloadBytes {
|
||||
return fmt.Errorf("download exceeds %d bytes", downloadBytes)
|
||||
}
|
||||
if n > 0 {
|
||||
_, _ = digest.Write(buffer[:n])
|
||||
if pauseErr := pauseMixedClient(ctx, 20*time.Millisecond); pauseErr != nil {
|
||||
return pauseErr
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if total != downloadBytes || !bytes.Equal(digest.Sum(nil), expectedHash[:]) {
|
||||
return fmt.Errorf("download length or digest mismatch: %d bytes", total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type pacedUpload struct {
|
||||
ctx context.Context
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (r *pacedUpload) Read(buffer []byte) (int, error) {
|
||||
if r.remaining == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(buffer) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := pauseMixedClient(r.ctx, 2*time.Millisecond); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := len(buffer)
|
||||
if n > 4096 {
|
||||
n = 4096
|
||||
}
|
||||
if n > r.remaining {
|
||||
n = r.remaining
|
||||
}
|
||||
for index := 0; index < n; index++ {
|
||||
buffer[index] = 'x'
|
||||
}
|
||||
r.remaining -= n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func pauseMixedClient(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
459
transport/http2/proxy_test.go
Normal file
459
transport/http2/proxy_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// This opt-in suite runs against a real Nginx executable. The Linux wrapper
|
||||
// builds the test binary separately, then runs it in an isolated Nginx image.
|
||||
func TestNginxHTTP2Proxy(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_HTTP2_PROXY_TEST") != "1" {
|
||||
t.Skip("use scripts/verifyHTTP2Proxy.py for real Nginx HTTP/2 upstream validation")
|
||||
}
|
||||
if output, err := exec.Command("nginx", "-v").CombinedOutput(); err != nil {
|
||||
t.Fatalf("nginx unavailable: %v: %s", err, output)
|
||||
} else {
|
||||
t.Logf("proxyVersion=%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
t.Run("TLSAndConnectionReuse", testProxyTLSAndReuse)
|
||||
t.Run("TimeoutAndCancellation", testProxyTimeoutAndCancellation)
|
||||
t.Run("GoAwayAndRollingDrain", testProxyGoAwayAndDrain)
|
||||
}
|
||||
|
||||
type proxyReply struct {
|
||||
Instance string `json:"instance"`
|
||||
RequestID string `json:"requestId"`
|
||||
Protocol string `json:"protocol"`
|
||||
ALPN string `json:"alpn"`
|
||||
Method string `json:"method"`
|
||||
BodyBytes int `json:"bodyBytes"`
|
||||
BodySHA256 string `json:"bodySha256"`
|
||||
}
|
||||
|
||||
type proxyOrigin struct {
|
||||
server *Server
|
||||
address string
|
||||
done chan struct{}
|
||||
serveErr error
|
||||
requests atomic.Uint64
|
||||
duplicates atomic.Uint64
|
||||
seen sync.Map
|
||||
}
|
||||
|
||||
func newProxyOrigin(t *testing.T, certificate tls.Certificate, instance string, beforeReply func(*http.Request)) *proxyOrigin {
|
||||
t.Helper()
|
||||
o := &proxyOrigin{done: make(chan struct{})}
|
||||
o.server = New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.ProtoMajor != 2 || request.TLS == nil || request.TLS.NegotiatedProtocol != "h2" || request.TLS.ServerName != "localhost" {
|
||||
t.Error("proxy upstream did not negotiate verified-name TLS/h2")
|
||||
http.Error(writer, "wrong upstream protocol", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
o.requests.Add(1)
|
||||
id := request.Header.Get("X-Request-ID")
|
||||
if _, loaded := o.seen.LoadOrStore(id, struct{}{}); loaded {
|
||||
o.duplicates.Add(1)
|
||||
}
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
http.Error(writer, "body read failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if beforeReply != nil {
|
||||
beforeReply(request)
|
||||
}
|
||||
if request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("X-Request-ID", id)
|
||||
_ = json.NewEncoder(writer).Encode(proxyReply{
|
||||
Instance: instance, RequestID: id, Protocol: request.Proto, ALPN: request.TLS.NegotiatedProtocol,
|
||||
Method: request.Method, BodyBytes: len(body), BodySHA256: fmt.Sprintf("%x", sha256.Sum256(body)),
|
||||
})
|
||||
}))
|
||||
o.server.MaxConnections = 16
|
||||
o.server.MaxConcurrentRequests = 32
|
||||
o.server.MaxConcurrentStreams = 32
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.address = listener.Addr().String()
|
||||
go func() {
|
||||
o.serveErr = o.server.ServeTLS(context.Background(), listener, &tls.Config{
|
||||
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
close(o.done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := o.shutdown(ctx); err != nil {
|
||||
t.Errorf("origin cleanup: %v", err)
|
||||
}
|
||||
stats := o.server.Stats()
|
||||
if stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || o.duplicates.Load() != 0 {
|
||||
t.Errorf("origin lifecycle/duplicate failure: stats=%+v duplicates=%d", stats, o.duplicates.Load())
|
||||
}
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
func (o *proxyOrigin) shutdown(ctx context.Context) error {
|
||||
if err := o.server.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-o.done:
|
||||
return o.serveErr
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func testProxyTLSAndReuse(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
origin := newProxyOrigin(t, certificate, "primary", nil)
|
||||
endpoint := startTestNginx(t, certificate, origin.address, "")
|
||||
client := proxyClient(t, roots)
|
||||
for index := 0; index < 32; index++ {
|
||||
payload := []byte(fmt.Sprintf("payload-%d", index))
|
||||
result, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodPost, fmt.Sprintf("reuse-%d", index), payload, 200)
|
||||
if err != nil || result.Instance != "primary" {
|
||||
t.Fatalf("reuse request %d: %+v %v", index, result, err)
|
||||
}
|
||||
}
|
||||
stats := origin.server.Stats()
|
||||
if stats.AcceptedConnections != 1 || stats.ActiveConnections != 1 || origin.requests.Load() != 32 {
|
||||
t.Fatalf("upstream TLS connection was not reused: %+v requests=%d", stats, origin.requests.Load())
|
||||
}
|
||||
for _, path := range []string{"/badTrust", "/badName"} {
|
||||
if _, err := proxyCall(context.Background(), client, endpoint+path, http.MethodGet, path, nil, 502); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if origin.requests.Load() != 32 {
|
||||
t.Fatal("an upstream with invalid certificate trust or hostname reached the handler")
|
||||
}
|
||||
if _, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "afterTLSFailures", nil, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true")
|
||||
}
|
||||
|
||||
func testProxyTimeoutAndCancellation(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
started, canceled := make(chan string, 2), make(chan string, 2)
|
||||
origin := newProxyOrigin(t, certificate, "primary", func(request *http.Request) {
|
||||
if request.URL.Path == "/timeout" || request.URL.Path == "/cancel" {
|
||||
started <- request.URL.Path
|
||||
<-request.Context().Done()
|
||||
canceled <- request.URL.Path
|
||||
}
|
||||
})
|
||||
endpoint := startTestNginx(t, certificate, origin.address, "")
|
||||
client := proxyClient(t, roots)
|
||||
begin := time.Now()
|
||||
if _, err := proxyCall(context.Background(), client, endpoint+"/timeout", http.MethodGet, "timeout", nil, 504); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Since(begin) > 2*time.Second {
|
||||
t.Fatal("upstream timeout was not bounded")
|
||||
}
|
||||
awaitProxyPath(t, started, "/timeout")
|
||||
awaitProxyPath(t, canceled, "/timeout")
|
||||
waitHTTP2Stats(t, origin.server, func(stats Stats) bool { return stats.ActiveRequests == 0 })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := proxyCall(ctx, client, endpoint+"/cancel", http.MethodGet, "cancel", nil, 200)
|
||||
result <- err
|
||||
}()
|
||||
awaitProxyPath(t, started, "/cancel")
|
||||
cancel()
|
||||
if err := serveResult(t, result); err == nil {
|
||||
t.Fatal("canceled proxy request unexpectedly succeeded")
|
||||
}
|
||||
awaitProxyPath(t, canceled, "/cancel")
|
||||
waitHTTP2Stats(t, origin.server, func(stats Stats) bool { return stats.ActiveRequests == 0 })
|
||||
if _, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "afterCancellation", nil, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats=%+v", origin.server.Stats())
|
||||
}
|
||||
|
||||
func testProxyGoAwayAndDrain(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
started := make(chan context.Context, 1)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
releaseOld := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
defer releaseOld()
|
||||
old := newProxyOrigin(t, certificate, "old", func(request *http.Request) {
|
||||
if request.URL.Path == "/hold" {
|
||||
started <- request.Context()
|
||||
select {
|
||||
case <-release:
|
||||
case <-request.Context().Done():
|
||||
}
|
||||
}
|
||||
})
|
||||
next := newProxyOrigin(t, certificate, "new", nil)
|
||||
endpoint := startTestNginx(t, certificate, old.address, next.address)
|
||||
client := proxyClient(t, roots)
|
||||
if reply, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "beforeRollout", nil, 200); err != nil || reply.Instance != "old" {
|
||||
t.Fatalf("initial request: %+v %v", reply, err)
|
||||
}
|
||||
held := make(chan error, 1)
|
||||
go func() {
|
||||
reply, err := proxyCall(context.Background(), client, endpoint+"/hold", http.MethodPost, "heldOld", []byte("finish exactly once"), 200)
|
||||
if err == nil && reply.Instance != "old" {
|
||||
err = fmt.Errorf("held request switched instance: %+v", reply)
|
||||
}
|
||||
held <- err
|
||||
}()
|
||||
var heldContext context.Context
|
||||
select {
|
||||
case heldContext = <-started:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("held request never reached old instance")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
stopped := make(chan error, 1)
|
||||
go func() { stopped <- old.shutdown(ctx) }()
|
||||
waitHTTP2Stats(t, old.server, func(stats Stats) bool { return stats.GoAwayFrames > 0 })
|
||||
if heldContext.Err() != nil {
|
||||
t.Fatal("GOAWAY canceled the in-flight request")
|
||||
}
|
||||
// The primary listener is closed, so Nginx must route new requests to the
|
||||
// backup while the old HTTP/2 stream is still draining. POSTs also prove
|
||||
// request bodies and IDs survive this transition without duplicate handlers.
|
||||
for index := 0; index < 20; index++ {
|
||||
reply, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodPost, fmt.Sprintf("rollout-%d", index), []byte(fmt.Sprint(index)), 200)
|
||||
if err != nil || reply.Instance != "new" {
|
||||
t.Fatalf("request during drain %d: %+v %v", index, reply, err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-held:
|
||||
t.Fatalf("held request completed before release: %v", err)
|
||||
default:
|
||||
}
|
||||
if old.requests.Load() != 2 || next.requests.Load() != 20 {
|
||||
t.Fatalf("rollout request accounting: old=%d new=%d", old.requests.Load(), next.requests.Load())
|
||||
}
|
||||
releaseOld()
|
||||
if err := serveResult(t, held); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := serveResult(t, stopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := old.server.Stats()
|
||||
if stats.ActiveRequests != 0 || stats.ActiveConnections != 0 || stats.GoAwayFrames == 0 {
|
||||
t.Fatalf("old instance failed to drain: %+v", stats)
|
||||
}
|
||||
t.Logf("proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats=%+v", stats)
|
||||
}
|
||||
|
||||
func awaitProxyPath(t *testing.T, events <-chan string, expected string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case path := <-events:
|
||||
if path != expected {
|
||||
t.Fatalf("handler event: got %s, want %s", path, expected)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for handler event: %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func proxyClient(t *testing.T, roots *x509.CertPool) *http.Client {
|
||||
t.Helper()
|
||||
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12, NextProtos: []string{"h2"},
|
||||
}, StrictMaxConcurrentStreams: true}
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
return &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
func proxyCall(ctx context.Context, client *http.Client, endpoint, method, id string, body []byte, expectedStatus int) (proxyReply, error) {
|
||||
var reply proxyReply
|
||||
request, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
request.Header.Set("X-Request-ID", id)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, 8192))
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if response.StatusCode != expectedStatus || response.ProtoMajor != 2 || response.TLS == nil || response.TLS.NegotiatedProtocol != "h2" || len(response.TLS.VerifiedChains) == 0 {
|
||||
return reply, fmt.Errorf("unverified edge TLS/h2 or status: %s body=%q", response.Status, data)
|
||||
}
|
||||
if expectedStatus != 200 {
|
||||
return reply, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &reply); err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if reply.RequestID != id || response.Header.Get("X-Request-ID") != id || reply.Protocol != "HTTP/2.0" || reply.ALPN != "h2" || reply.Method != method || reply.BodyBytes != len(body) || reply.BodySHA256 != fmt.Sprintf("%x", sha256.Sum256(body)) {
|
||||
return reply, fmt.Errorf("upstream protocol, request ID or body mismatch: %+v", reply)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func startTestNginx(t *testing.T, certificate tls.Certificate, primary, backup string) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
certFile, keyFile := filepath.Join(directory, "cert.pem"), filepath.Join(directory, "key.pem")
|
||||
writeProxyFile(t, certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}))
|
||||
key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeProxyFile(t, keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}))
|
||||
wrong, _ := testCertificate(t)
|
||||
wrongFile := filepath.Join(directory, "wrong.pem")
|
||||
writeProxyFile(t, wrongFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: wrong.Certificate[0]}))
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
backupLine := ""
|
||||
if backup != "" {
|
||||
backupLine = "server " + backup + " backup;"
|
||||
}
|
||||
config := fmt.Sprintf(`daemon off;
|
||||
master_process off;
|
||||
pid %q;
|
||||
error_log stderr info;
|
||||
events { worker_connections 128; }
|
||||
http {
|
||||
access_log off;
|
||||
client_body_temp_path %q;
|
||||
proxy_temp_path %q;
|
||||
fastcgi_temp_path %q;
|
||||
uwsgi_temp_path %q;
|
||||
scgi_temp_path %q;
|
||||
upstream origins { server %s max_fails=1 fail_timeout=30s; %s keepalive 16; }
|
||||
upstream badTrust { server %s; }
|
||||
upstream badName { server %s; }
|
||||
server {
|
||||
listen %s ssl;
|
||||
http2 on;
|
||||
ssl_certificate %q;
|
||||
ssl_certificate_key %q;
|
||||
proxy_http_version 2;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header X-Request-ID $http_x_request_id;
|
||||
proxy_ssl_verify on;
|
||||
proxy_ssl_trusted_certificate %q;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name localhost;
|
||||
proxy_connect_timeout 500ms;
|
||||
proxy_send_timeout 2s;
|
||||
proxy_read_timeout 10s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_next_upstream error timeout;
|
||||
proxy_next_upstream_tries 2;
|
||||
location / { proxy_pass https://origins; }
|
||||
location = /timeout { proxy_read_timeout 250ms; proxy_next_upstream off; proxy_pass https://origins; }
|
||||
location = /badTrust { proxy_ssl_trusted_certificate %q; proxy_next_upstream off; proxy_pass https://badTrust; }
|
||||
location = /badName { proxy_ssl_name wrong.invalid; proxy_next_upstream off; proxy_pass https://badName; }
|
||||
}
|
||||
}
|
||||
`, filepath.Join(directory, "nginx.pid"), filepath.Join(directory, "clientBody"), filepath.Join(directory, "proxyBody"),
|
||||
filepath.Join(directory, "fastcgi"), filepath.Join(directory, "uwsgi"), filepath.Join(directory, "scgi"),
|
||||
primary, backupLine, primary, primary, address, certFile, keyFile, certFile, wrongFile)
|
||||
configFile := filepath.Join(directory, "nginx.conf")
|
||||
writeProxyFile(t, configFile, []byte(config))
|
||||
logFile := filepath.Join(directory, "nginx.log")
|
||||
output, err := os.Create(logFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command("nginx", "-p", directory+"/", "-c", configFile)
|
||||
command.Stdout, command.Stderr = output, output
|
||||
if err := command.Start(); err != nil {
|
||||
_ = output.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
var processErr error
|
||||
go func() { processErr = command.Wait(); close(done) }()
|
||||
t.Cleanup(func() {
|
||||
_ = command.Process.Signal(syscall.SIGQUIT)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = command.Process.Kill()
|
||||
<-done
|
||||
t.Error("nginx did not exit after SIGQUIT")
|
||||
}
|
||||
_ = output.Close()
|
||||
if processErr != nil || t.Failed() {
|
||||
log, _ := os.ReadFile(logFile)
|
||||
t.Logf("nginx exit=%v log:\n%s", processErr, log)
|
||||
}
|
||||
if processErr != nil {
|
||||
t.Errorf("nginx exit: %v", processErr)
|
||||
}
|
||||
})
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("nginx exited during startup: %v", processErr)
|
||||
default:
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return "https://" + address
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("nginx did not start")
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeProxyFile(t *testing.T, path string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
497
transport/http2/server.go
Normal file
497
transport/http2/server.go
Normal file
@@ -0,0 +1,497 @@
|
||||
// Package http2 provides an opt-in HTTP/2 transport experiment.
|
||||
//
|
||||
// It owns the listener, TLS/ALPN setup, connection admission and global
|
||||
// request backpressure while using x/net/http2 for frame parsing and stream
|
||||
// flow control. It is deliberately separate from transport/native, whose
|
||||
// production preview remains HTTP/1.1.
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultShutdownTimeout = 10 * time.Second
|
||||
defaultTLSHandshake = 10 * time.Second
|
||||
defaultMaxBodyBytes = 8 << 20
|
||||
http2ALPNProtocol = "h2"
|
||||
)
|
||||
|
||||
// Server is an opt-in HTTP/2 transport. Run uses cleartext h2c for local
|
||||
// experiments; RunTLS or ServeTLS should be used for deployed TLS traffic.
|
||||
// HTTP/2 stream limits and global request admission are independent: a client
|
||||
// may open many streams, but only admitted handlers run concurrently.
|
||||
type Server struct {
|
||||
MaxConnections int
|
||||
MaxConcurrentRequests int
|
||||
MaxConcurrentStreams uint32
|
||||
MaxHeaderBytes int
|
||||
MaxBodyBytes int64
|
||||
IdleTimeout time.Duration
|
||||
TLSHandshakeTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
TLSConfig *tls.Config
|
||||
|
||||
mu sync.Mutex
|
||||
handler http.Handler
|
||||
listener net.Listener
|
||||
shutdownConfig *http.Server
|
||||
shutdownDone chan struct{}
|
||||
shutdownOnce sync.Once
|
||||
closing bool
|
||||
cancel context.CancelFunc
|
||||
conns map[net.Conn]struct{}
|
||||
protocolConns map[net.Conn]struct{}
|
||||
connSlots chan struct{}
|
||||
requestSlots chan struct{}
|
||||
accepted atomic.Uint64
|
||||
rejectedConns atomic.Uint64
|
||||
rejectedReqs atomic.Uint64
|
||||
protocolErrors atomic.Uint64
|
||||
goAwaySent atomic.Uint64
|
||||
rstStreams atomic.Uint64
|
||||
goAwayFrames atomic.Uint64
|
||||
activeConns atomic.Int64
|
||||
activeRequests atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// Stats is a point-in-time view of HTTP/2 transport admission and lifecycle.
|
||||
// Accepted and rejected counters are monotonic; active counters are gauges.
|
||||
type Stats struct {
|
||||
AcceptedConnections uint64 `json:"acceptedConnections"`
|
||||
RejectedConnections uint64 `json:"rejectedConnections"`
|
||||
RejectedRequests uint64 `json:"rejectedRequests"`
|
||||
ProtocolErrors uint64 `json:"protocolErrors"`
|
||||
GoAwaySent uint64 `json:"goAwaySent"`
|
||||
RSTStreams uint64 `json:"rstStreams"`
|
||||
GoAwayFrames uint64 `json:"goAwayFrames"`
|
||||
ActiveConnections int64 `json:"activeConnections"`
|
||||
ActiveRequests int64 `json:"activeRequests"`
|
||||
}
|
||||
|
||||
// New creates an HTTP/2 transport with the supplied handler. The handler may
|
||||
// also be supplied by App.RunWith through the Runner interface.
|
||||
func New(handler http.Handler) *Server {
|
||||
return &Server{
|
||||
handler: handler,
|
||||
conns: make(map[net.Conn]struct{}),
|
||||
MaxBodyBytes: defaultMaxBodyBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns a lock-free snapshot suitable for a metrics handler.
|
||||
func (s *Server) Stats() Stats {
|
||||
if s == nil {
|
||||
return Stats{}
|
||||
}
|
||||
return Stats{
|
||||
AcceptedConnections: s.accepted.Load(),
|
||||
RejectedConnections: s.rejectedConns.Load(),
|
||||
RejectedRequests: s.rejectedReqs.Load(),
|
||||
ProtocolErrors: s.protocolErrors.Load(),
|
||||
GoAwaySent: s.goAwaySent.Load(),
|
||||
RSTStreams: s.rstStreams.Load(),
|
||||
GoAwayFrames: s.goAwayFrames.Load(),
|
||||
ActiveConnections: s.activeConns.Load(),
|
||||
ActiveRequests: s.activeRequests.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run implements app.Runner. Without TLSConfig it starts an h2c listener for
|
||||
// local testing. Use RunTLS or TLSConfig for real HTTPS traffic.
|
||||
func (s *Server) Run(ctx context.Context, handler http.Handler, address string) error {
|
||||
if handler != nil {
|
||||
s.handler = handler
|
||||
}
|
||||
if s.handler == nil {
|
||||
return errors.New("go-omni/http2: nil handler")
|
||||
}
|
||||
if address == "" {
|
||||
address = ":8080"
|
||||
}
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.TLSConfig != nil {
|
||||
return s.ServeTLS(ctx, listener, s.TLSConfig)
|
||||
}
|
||||
return s.Serve(ctx, listener)
|
||||
}
|
||||
|
||||
// RunTLS loads a certificate pair and serves HTTP/2 over TLS with ALPN h2.
|
||||
func (s *Server) RunTLS(ctx context.Context, handler http.Handler, address, certFile, keyFile string) error {
|
||||
if handler != nil {
|
||||
s.handler = handler
|
||||
}
|
||||
if s.handler == nil {
|
||||
return errors.New("go-omni/http2: nil handler")
|
||||
}
|
||||
if address == "" {
|
||||
address = ":8443"
|
||||
}
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ServeTLS(ctx, listener, &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12})
|
||||
}
|
||||
|
||||
// ServeTLS wraps listener with TLS and requires ALPN h2. A caller-provided
|
||||
// config is cloned, so the original value is never mutated.
|
||||
func (s *Server) ServeTLS(ctx context.Context, listener net.Listener, config *tls.Config) error {
|
||||
if listener == nil {
|
||||
return errors.New("go-omni/http2: nil listener")
|
||||
}
|
||||
if config == nil {
|
||||
return errors.New("go-omni/http2: nil TLS config")
|
||||
}
|
||||
prepared, err := prepareTLSConfig(config)
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
return err
|
||||
}
|
||||
return s.Serve(ctx, tls.NewListener(listener, prepared))
|
||||
}
|
||||
|
||||
func prepareTLSConfig(config *tls.Config) (*tls.Config, error) {
|
||||
clone := config.Clone()
|
||||
if len(clone.NextProtos) > 0 {
|
||||
for _, protocol := range clone.NextProtos {
|
||||
if protocol != http2ALPNProtocol {
|
||||
return nil, fmt.Errorf("go-omni/http2: unsupported TLS ALPN protocol %q; only %q is supported", protocol, http2ALPNProtocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
clone.NextProtos = []string{http2ALPNProtocol}
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// Serve accepts h2c connections from listener until ctx is canceled.
|
||||
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
if listener == nil {
|
||||
return errors.New("go-omni/http2: nil listener")
|
||||
}
|
||||
if s.handler == nil {
|
||||
return errors.New("go-omni/http2: nil handler")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.cancel != nil {
|
||||
s.mu.Unlock()
|
||||
return errors.New("go-omni/http2: server is already running")
|
||||
}
|
||||
// Keep the connection context independent from the caller's context. A
|
||||
// canceled parent starts graceful shutdown; it must not cancel active
|
||||
// streams before the GOAWAY/drain phase has completed.
|
||||
baseCtx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.listener = listener
|
||||
s.shutdownDone = make(chan struct{})
|
||||
s.shutdownOnce = sync.Once{}
|
||||
s.closing = false
|
||||
if s.conns == nil {
|
||||
s.conns = make(map[net.Conn]struct{})
|
||||
}
|
||||
if s.protocolConns == nil {
|
||||
s.protocolConns = make(map[net.Conn]struct{})
|
||||
}
|
||||
if s.MaxConnections > 0 {
|
||||
s.connSlots = make(chan struct{}, s.MaxConnections)
|
||||
} else {
|
||||
s.connSlots = nil
|
||||
}
|
||||
if s.MaxConcurrentRequests > 0 {
|
||||
s.requestSlots = make(chan struct{}, s.MaxConcurrentRequests)
|
||||
} else {
|
||||
s.requestSlots = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
maxBodyBytes := s.MaxBodyBytes
|
||||
if maxBodyBytes <= 0 {
|
||||
maxBodyBytes = defaultMaxBodyBytes
|
||||
}
|
||||
requestHandler := http.Handler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.ContentLength > maxBodyBytes {
|
||||
_ = request.Body.Close()
|
||||
http.Error(writer, "request body too large\n", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, maxBodyBytes)
|
||||
s.handler.ServeHTTP(writer, request)
|
||||
}))
|
||||
gate := &requestGate{server: s, next: requestHandler, slots: s.requestSlots}
|
||||
protocolServer := &xhttp2.Server{
|
||||
MaxConcurrentStreams: s.MaxConcurrentStreams,
|
||||
IdleTimeout: s.IdleTimeout,
|
||||
CountError: func(string) {
|
||||
s.protocolErrors.Add(1)
|
||||
},
|
||||
}
|
||||
shutdownConfig := &http.Server{IdleTimeout: s.IdleTimeout, MaxHeaderBytes: s.MaxHeaderBytes}
|
||||
if err := xhttp2.ConfigureServer(shutdownConfig, protocolServer); err != nil {
|
||||
cancel()
|
||||
s.mu.Lock()
|
||||
s.listener = nil
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.shutdownConfig = shutdownConfig
|
||||
s.mu.Unlock()
|
||||
contextWakeDone := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Wake Accept. Serve performs the graceful drain synchronously so
|
||||
// its return value reports a shutdown timeout to the caller.
|
||||
_ = listener.Close()
|
||||
case <-contextWakeDone:
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
close(contextWakeDone)
|
||||
s.mu.Lock()
|
||||
closing := s.closing
|
||||
shutdownDone := s.shutdownDone
|
||||
s.listener = nil
|
||||
s.shutdownConfig = nil
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
if !closing {
|
||||
cancel()
|
||||
} else if shutdownDone != nil {
|
||||
<-shutdownDone
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil || s.isClosing() {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.accepted.Add(1)
|
||||
if slots := s.connSlots; slots != nil {
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
default:
|
||||
s.rejectedConns.Add(1)
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closing {
|
||||
s.mu.Unlock()
|
||||
if s.connSlots != nil {
|
||||
<-s.connSlots
|
||||
}
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
s.conns[conn] = struct{}{}
|
||||
s.activeConns.Add(1)
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
go s.serveConn(baseCtx, protocolServer, shutdownConfig, gate, conn)
|
||||
}
|
||||
if ctx.Err() != nil && !s.isClosing() {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), s.shutdownTimeout())
|
||||
err := s.Shutdown(shutdownCtx)
|
||||
shutdownCancel()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) shutdownTimeout() time.Duration {
|
||||
if s.ShutdownTimeout > 0 {
|
||||
return s.ShutdownTimeout
|
||||
}
|
||||
return defaultShutdownTimeout
|
||||
}
|
||||
|
||||
func (s *Server) serveConn(ctx context.Context, protocolServer *xhttp2.Server, baseConfig *http.Server, handler http.Handler, conn net.Conn) {
|
||||
defer s.wg.Done()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, conn)
|
||||
delete(s.protocolConns, conn)
|
||||
s.activeConns.Add(-1)
|
||||
s.mu.Unlock()
|
||||
if s.connSlots != nil {
|
||||
<-s.connSlots
|
||||
}
|
||||
_ = conn.Close()
|
||||
}()
|
||||
if tlsConn, ok := conn.(*tls.Conn); ok {
|
||||
_ = conn.SetDeadline(time.Now().Add(s.tlsHandshakeTimeout()))
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closing {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.protocolConns[conn] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
observed := &observedConn{
|
||||
Conn: conn,
|
||||
readParser: frameParser{prefaceRemain: 24},
|
||||
onFrame: func(direction frameDirection, frameType byte) {
|
||||
if direction == frameRead && frameType == frameTypeRSTStream {
|
||||
s.rstStreams.Add(1)
|
||||
}
|
||||
if direction == frameWrite && frameType == frameTypeGoAway {
|
||||
s.goAwayFrames.Add(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
var protocolConn net.Conn = observed
|
||||
if tlsConn, ok := conn.(*tls.Conn); ok {
|
||||
protocolConn = &observedTLSConn{observedConn: observed, state: tlsConn.ConnectionState()}
|
||||
}
|
||||
protocolServer.ServeConn(protocolConn, &xhttp2.ServeConnOpts{
|
||||
Context: ctx, Handler: handler, BaseConfig: baseConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) tlsHandshakeTimeout() time.Duration {
|
||||
if s.TLSHandshakeTimeout > 0 {
|
||||
return s.TLSHandshakeTimeout
|
||||
}
|
||||
return defaultTLSHandshake
|
||||
}
|
||||
|
||||
// Shutdown stops accepting connections and waits for active HTTP/2 sessions.
|
||||
// If the context expires, remaining connections are closed so handlers can
|
||||
// observe cancellation through their request contexts.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
timeout := s.ShutdownTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultShutdownTimeout
|
||||
}
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.cancel == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
alreadyClosing := s.closing
|
||||
s.closing = true
|
||||
listener := s.listener
|
||||
shutdownConfig := s.shutdownConfig
|
||||
cancel := s.cancel
|
||||
connections := make([]net.Conn, 0, len(s.conns))
|
||||
for conn := range s.conns {
|
||||
connections = append(connections, conn)
|
||||
}
|
||||
protocolConnectionCount := len(s.protocolConns)
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
_ = listener.Close()
|
||||
}
|
||||
// x/net/http2's configured shutdown callback sends GOAWAY and lets active
|
||||
// streams finish. The base request context remains live until the timeout
|
||||
// path below, where connections are forcibly closed.
|
||||
if shutdownConfig != nil && !alreadyClosing {
|
||||
s.goAwaySent.Add(uint64(protocolConnectionCount))
|
||||
_ = shutdownConfig.Shutdown(context.Background())
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
cancel()
|
||||
s.finishShutdown()
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
for _, conn := range connections {
|
||||
_ = conn.Close()
|
||||
}
|
||||
cancel()
|
||||
s.finishShutdown()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) finishShutdown() {
|
||||
s.mu.Lock()
|
||||
done := s.shutdownDone
|
||||
s.mu.Unlock()
|
||||
if done != nil {
|
||||
s.shutdownOnce.Do(func() { close(done) })
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) isClosing() bool {
|
||||
s.mu.Lock()
|
||||
closing := s.closing
|
||||
s.mu.Unlock()
|
||||
return closing
|
||||
}
|
||||
|
||||
type requestGate struct {
|
||||
server *Server
|
||||
next http.Handler
|
||||
slots chan struct{}
|
||||
}
|
||||
|
||||
func (g *requestGate) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if g.slots != nil {
|
||||
select {
|
||||
case g.slots <- struct{}{}:
|
||||
g.server.activeRequests.Add(1)
|
||||
defer func() {
|
||||
<-g.slots
|
||||
g.server.activeRequests.Add(-1)
|
||||
}()
|
||||
default:
|
||||
g.server.rejectedReqs.Add(1)
|
||||
http.Error(writer, "server busy\n", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
g.server.activeRequests.Add(1)
|
||||
defer g.server.activeRequests.Add(-1)
|
||||
}
|
||||
g.next.ServeHTTP(writer, request)
|
||||
}
|
||||
453
transport/http2/server_test.go
Normal file
453
transport/http2/server_test.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func TestServerHandlesH2CRequest(t *testing.T) {
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.ProtoMajor != 2 {
|
||||
http.Error(writer, "not HTTP/2", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write([]byte("ok"))
|
||||
}))
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
response, err := client.Get("http://" + address + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", response.StatusCode)
|
||||
}
|
||||
if stats := server.Stats(); stats.AcceptedConnections != 1 {
|
||||
t.Fatalf("unexpected HTTP/2 stats: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerEnforcesGlobalRequestLimitAndRecovers(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/hold" {
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
}
|
||||
_, _ = writer.Write([]byte("ok"))
|
||||
}))
|
||||
server.MaxConcurrentRequests = 1
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
firstDone := make(chan *http.Response, 1)
|
||||
firstErr := make(chan error, 1)
|
||||
go func() {
|
||||
response, err := client.Get("http://" + address + "/hold")
|
||||
if err != nil {
|
||||
firstErr <- err
|
||||
return
|
||||
}
|
||||
firstDone <- response
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case err := <-firstErr:
|
||||
t.Fatal(err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first HTTP/2 stream did not start")
|
||||
}
|
||||
|
||||
response, err := client.Get("http://" + address + "/busy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 at stream limit, got %d", response.StatusCode)
|
||||
}
|
||||
if stats := server.Stats(); stats.RejectedRequests != 1 {
|
||||
t.Fatalf("expected one rejected stream, got %+v", stats)
|
||||
}
|
||||
|
||||
close(release)
|
||||
select {
|
||||
case response = <-firstDone:
|
||||
response.Body.Close()
|
||||
case err := <-firstErr:
|
||||
t.Fatal(err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("held HTTP/2 stream did not finish")
|
||||
}
|
||||
response, err = client.Get("http://" + address + "/recovered")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("request limit did not recover, got %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRejectsBodyAboveLimitAndRecovers(t *testing.T) {
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write(body)
|
||||
}))
|
||||
server.MaxBodyBytes = 4
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, "http://"+address+"/too-large", bytes.NewReader([]byte("12345")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("expected 413 for oversized body, got %d", response.StatusCode)
|
||||
}
|
||||
|
||||
request, err = http.NewRequest(http.MethodPost, "http://"+address+"/ok", bytes.NewReader([]byte("1234")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err = client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "1234" {
|
||||
t.Fatalf("body limit did not recover: status=%d body=%q err=%v", response.StatusCode, body, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRejectsHeadersAboveLimitAndRecovers(t *testing.T) {
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = io.WriteString(writer, "ok")
|
||||
}))
|
||||
server.MaxHeaderBytes = 1024
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, "http://"+address+"/too-large", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("X-Large", strings.Repeat("x", 2048))
|
||||
response, err := client.Do(request)
|
||||
if err == nil {
|
||||
response.Body.Close()
|
||||
if response.StatusCode < http.StatusBadRequest {
|
||||
t.Fatalf("oversized headers unexpectedly succeeded with status %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
response, err = client.Get("http://" + address + "/recovered")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "ok" {
|
||||
t.Fatalf("header limit did not recover: status=%d body=%q err=%v", response.StatusCode, body, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerMultiplexes128Streams(t *testing.T) {
|
||||
started := make(chan struct{}, 128)
|
||||
release := make(chan struct{})
|
||||
var active, maximum atomic.Int32
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
current := active.Add(1)
|
||||
for {
|
||||
seen := maximum.Load()
|
||||
if current <= seen || maximum.CompareAndSwap(seen, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
active.Add(-1)
|
||||
_, _ = io.WriteString(writer, request.URL.Path)
|
||||
}))
|
||||
server.MaxConcurrentStreams = 128
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
StrictMaxConcurrentStreams: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
type multiplexedResponse struct {
|
||||
response *http.Response
|
||||
expected string
|
||||
}
|
||||
responses := make(chan multiplexedResponse, 128)
|
||||
errorsCh := make(chan error, 128)
|
||||
for i := 0; i < 128; i++ {
|
||||
go func(i int) {
|
||||
response, err := client.Get(fmt.Sprintf("http://%s/stream-%d", address, i))
|
||||
if err != nil {
|
||||
errorsCh <- err
|
||||
return
|
||||
}
|
||||
responses <- multiplexedResponse{response: response, expected: fmt.Sprintf("/stream-%d", i)}
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 128; i++ {
|
||||
select {
|
||||
case <-started:
|
||||
case err := <-errorsCh:
|
||||
close(release)
|
||||
t.Fatal(err)
|
||||
case <-time.After(5 * time.Second):
|
||||
close(release)
|
||||
t.Fatal("HTTP/2 streams did not reach the handler")
|
||||
}
|
||||
}
|
||||
if got := maximum.Load(); got < 128 {
|
||||
close(release)
|
||||
t.Fatalf("expected 128 concurrent streams, got %d", got)
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveConnections != 1 {
|
||||
close(release)
|
||||
t.Fatalf("expected one multiplexed connection, got %+v", stats)
|
||||
}
|
||||
close(release)
|
||||
for i := 0; i < 128; i++ {
|
||||
select {
|
||||
case result := <-responses:
|
||||
body, err := io.ReadAll(result.response.Body)
|
||||
result.response.Body.Close()
|
||||
if err != nil || result.response.StatusCode != http.StatusOK || string(body) != result.expected {
|
||||
t.Fatalf("unexpected multiplexed response: status=%d body=%q expected=%q err=%v", result.response.StatusCode, body, result.expected, err)
|
||||
}
|
||||
case err := <-errorsCh:
|
||||
t.Fatal(err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("HTTP/2 streams did not finish")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerStreamsLargeBodyWithFlowControl(t *testing.T) {
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
var total int64
|
||||
buffer := make([]byte, 16<<10)
|
||||
for {
|
||||
n, err := request.Body.Read(buffer)
|
||||
total += int64(n)
|
||||
if n > 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, "%d", total)
|
||||
}))
|
||||
server.MaxBodyBytes = 4 << 20
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
payload := bytes.Repeat([]byte("x"), 2<<20)
|
||||
request, err := http.NewRequest(http.MethodPost, "http://"+address+"/large", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "2097152" {
|
||||
t.Fatalf("large body flow-control result: status=%d body=%q err=%v", response.StatusCode, body, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTLSUsesH2ALPN(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.TLS == nil || request.TLS.NegotiatedProtocol != "h2" {
|
||||
http.Error(writer, "missing h2 ALPN", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
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,
|
||||
})
|
||||
}()
|
||||
stop := func() {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
defer stop()
|
||||
|
||||
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
}}
|
||||
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
response, err := client.Get("https://" + listener.Addr().String() + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("unexpected TLS status: %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func startServer(t *testing.T, server *Server) (string, func()) {
|
||||
t.Helper()
|
||||
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) }()
|
||||
stop := func() {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
return listener.Addr().String(), stop
|
||||
}
|
||||
|
||||
func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
DNSNames: []string{"localhost"},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate := tls.Certificate{
|
||||
Certificate: [][]byte{der},
|
||||
PrivateKey: key,
|
||||
}
|
||||
certificate.Leaf = leaf
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
return certificate, roots
|
||||
}
|
||||
220
transport/http2/soak_test.go
Normal file
220
transport/http2/soak_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// TestTLSSoak is short by default so it remains part of normal package
|
||||
// verification. Linux acceptance runs set GO_OMNI_HTTP2_SOAK_DURATION=30m.
|
||||
func TestTLSSoak(t *testing.T) {
|
||||
started := time.Now()
|
||||
duration := soakDuration(t)
|
||||
logSoakResources(t, started, "before", 0, 0, Stats{})
|
||||
|
||||
certificate, roots := testCertificate(t)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/slow":
|
||||
timer := time.NewTimer(10 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
}
|
||||
case "/body":
|
||||
if _, err := io.Copy(io.Discard, request.Body); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
writer.Header().Set("X-Soak-Request-Id", request.Header.Get("X-Soak-Request-Id"))
|
||||
_, _ = io.WriteString(writer, request.URL.Path)
|
||||
}))
|
||||
server.MaxConnections = 8
|
||||
server.MaxConcurrentRequests = 128
|
||||
server.MaxConcurrentStreams = 128
|
||||
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,
|
||||
})
|
||||
}()
|
||||
|
||||
transport := &xhttp2.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots,
|
||||
ServerName: "localhost",
|
||||
MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
},
|
||||
StrictMaxConcurrentStreams: true,
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
|
||||
var requests, failures atomic.Uint64
|
||||
var firstFailure string
|
||||
var failureOnce sync.Once
|
||||
recordFailure := func(message string) {
|
||||
failures.Add(1)
|
||||
failureOnce.Do(func() { firstFailure = message })
|
||||
}
|
||||
monitorStop, monitorDone := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(monitorDone)
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
logSoakResources(t, started, "running", requests.Load(), failures.Load(), server.Stats())
|
||||
case <-monitorStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
close(monitorStop)
|
||||
<-monitorDone
|
||||
}()
|
||||
deadline := time.Now().Add(duration)
|
||||
var workers sync.WaitGroup
|
||||
for worker := 0; worker < 128; worker++ {
|
||||
workers.Add(1)
|
||||
go func(worker int) {
|
||||
defer workers.Done()
|
||||
for sequence := uint64(0); time.Now().Before(deadline); sequence++ {
|
||||
path := "/health"
|
||||
method := http.MethodGet
|
||||
var body io.Reader
|
||||
if worker%32 == 0 {
|
||||
path = "/slow"
|
||||
}
|
||||
if worker%64 == 0 {
|
||||
path = "/body"
|
||||
method = http.MethodPost
|
||||
body = &repeatingReader{remaining: 64 << 10}
|
||||
}
|
||||
requestContext, cancelRequest := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
request, err := http.NewRequestWithContext(requestContext, method, "https://"+listener.Addr().String()+path, body)
|
||||
if err != nil {
|
||||
cancelRequest()
|
||||
recordFailure(err.Error())
|
||||
continue
|
||||
}
|
||||
requestID := strconv.Itoa(worker) + ":" + strconv.FormatUint(sequence, 10)
|
||||
request.Header.Set("X-Soak-Request-Id", requestID)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
cancelRequest()
|
||||
recordFailure(err.Error())
|
||||
continue
|
||||
}
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||
response.Body.Close()
|
||||
cancelRequest()
|
||||
if readErr != nil {
|
||||
recordFailure(readErr.Error())
|
||||
continue
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || string(responseBody) != path || response.Header.Get("X-Soak-Request-Id") != requestID {
|
||||
recordFailure("unexpected status, protocol, body or request ID: " + response.Status)
|
||||
continue
|
||||
}
|
||||
requests.Add(1)
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
workers.Wait()
|
||||
transport.CloseIdleConnections()
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
shutdownErr := server.Shutdown(shutdownContext)
|
||||
cancel()
|
||||
if shutdownErr != nil {
|
||||
t.Fatalf("TLS soak shutdown: %v", shutdownErr)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("TLS soak serve: %v", err)
|
||||
}
|
||||
logSoakResources(t, started, "after", requests.Load(), failures.Load(), server.Stats())
|
||||
if failures.Load() != 0 || requests.Load() == 0 {
|
||||
t.Fatalf("TLS soak requests=%d failures=%d firstFailure=%q stats=%+v", requests.Load(), failures.Load(), firstFailure, server.Stats())
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 {
|
||||
t.Fatalf("TLS soak protocol errors, unexpected rejection or unreclaimed resources: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func soakDuration(t *testing.T) time.Duration {
|
||||
t.Helper()
|
||||
if value := os.Getenv("GO_OMNI_HTTP2_SOAK_DURATION"); value != "" {
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil || duration <= 0 {
|
||||
t.Fatalf("invalid GO_OMNI_HTTP2_SOAK_DURATION %q: %v", value, err)
|
||||
}
|
||||
return duration
|
||||
}
|
||||
return 3 * time.Second
|
||||
}
|
||||
|
||||
// These measurements cover the client and server in the same test process.
|
||||
// RSS includes race runtime allocations that do not appear in Go heap stats.
|
||||
func logSoakResources(t *testing.T, started time.Time, phase string, requests, failures uint64, stats Stats) {
|
||||
t.Helper()
|
||||
var memory runtime.MemStats
|
||||
runtime.ReadMemStats(&memory)
|
||||
rssBytes, fileDescriptors := int64(-1), -1
|
||||
if data, err := os.ReadFile("/proc/self/statm"); err == nil {
|
||||
if fields := strings.Fields(string(data)); len(fields) >= 2 {
|
||||
if pages, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||||
rssBytes = pages * int64(os.Getpagesize())
|
||||
}
|
||||
}
|
||||
}
|
||||
if entries, err := os.ReadDir("/proc/self/fd"); err == nil {
|
||||
fileDescriptors = len(entries)
|
||||
}
|
||||
t.Logf("phase=%s elapsed=%s requests=%d failures=%d heapBytes=%d rssBytes=%d goroutines=%d fileDescriptors=%d stats=%+v",
|
||||
phase, time.Since(started).Round(time.Millisecond), requests, failures,
|
||||
memory.HeapAlloc, rssBytes, runtime.NumGoroutine(), fileDescriptors, stats)
|
||||
}
|
||||
|
||||
type repeatingReader struct {
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (r *repeatingReader) Read(buffer []byte) (int, error) {
|
||||
if r.remaining == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(buffer) > r.remaining {
|
||||
buffer = buffer[:r.remaining]
|
||||
}
|
||||
for index := range buffer {
|
||||
buffer[index] = 'x'
|
||||
}
|
||||
r.remaining -= len(buffer)
|
||||
return len(buffer), nil
|
||||
}
|
||||
|
||||
func (r *repeatingReader) Close() error { return nil }
|
||||
119
transport/internal/routing/pattern.go
Normal file
119
transport/internal/routing/pattern.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Package routing defines registration rules shared by the built-in adapters.
|
||||
// Request dispatch remains the responsibility of each adapter.
|
||||
package routing
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var Methods = [...]string{
|
||||
http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
|
||||
http.MethodHead, http.MethodOptions, http.MethodDelete, http.MethodConnect, http.MethodTrace,
|
||||
}
|
||||
|
||||
type Segment struct {
|
||||
Literal string
|
||||
Name string
|
||||
Wildcard bool
|
||||
}
|
||||
|
||||
// Parse accepts absolute paths, whole-segment parameters and a terminal
|
||||
// catch-all. It rejects ambiguous patterns before an adapter registers them.
|
||||
func Parse(method, path string, handler http.Handler) []Segment {
|
||||
if handler == nil {
|
||||
panic("go-omni: nil route handler")
|
||||
}
|
||||
if method == "" || strings.IndexFunc(method, func(r rune) bool { return r < 'A' || r > 'Z' }) >= 0 {
|
||||
panic("go-omni: route method must contain uppercase letters")
|
||||
}
|
||||
if path == "" || path[0] != '/' {
|
||||
panic("go-omni: route path must start with /")
|
||||
}
|
||||
parts := strings.Split(path[1:], "/")
|
||||
segments := make([]Segment, len(parts))
|
||||
names := make(map[string]bool)
|
||||
for i, part := range parts {
|
||||
if part == "." || part == ".." || strings.ContainsAny(part, "?#") || (part == "" && i != len(parts)-1) {
|
||||
panic("go-omni: invalid route path " + path)
|
||||
}
|
||||
if strings.HasPrefix(part, ":") || strings.HasPrefix(part, "*") {
|
||||
name := part[1:]
|
||||
if name == "" || strings.ContainsAny(name, ":*") || names[name] {
|
||||
panic("go-omni: route parameters must have unique, non-empty names")
|
||||
}
|
||||
wildcard := part[0] == '*'
|
||||
if wildcard && i != len(parts)-1 {
|
||||
panic("go-omni: catch-all must be the final segment")
|
||||
}
|
||||
names[name] = true
|
||||
segments[i] = Segment{Name: name, Wildcard: wildcard}
|
||||
} else {
|
||||
if strings.ContainsAny(part, ":*") {
|
||||
panic("go-omni: parameters must occupy a whole path segment")
|
||||
}
|
||||
segments[i] = Segment{Literal: part}
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// CheckConflict enforces the portable ambiguity rules for one method.
|
||||
func CheckConflict(existing, next []Segment) {
|
||||
for i := 0; i < len(existing) && i < len(next); i++ {
|
||||
a, b := existing[i], next[i]
|
||||
if a.Wildcard || b.Wildcard {
|
||||
panic("go-omni: catch-all conflicts with an existing route")
|
||||
}
|
||||
if a.Name != "" && b.Name != "" {
|
||||
if a.Name != b.Name {
|
||||
panic("go-omni: conflicting parameter names at the same route prefix")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a != b {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(existing) == len(next) {
|
||||
panic("go-omni: duplicate route")
|
||||
}
|
||||
}
|
||||
|
||||
// Join supports relative group paths while preserving a final slash.
|
||||
func Join(prefix, path string) string {
|
||||
if path == "" {
|
||||
if prefix == "" {
|
||||
return "/"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
}
|
||||
|
||||
// Match uses the decoded URL.Path without slash normalization or redirects.
|
||||
func Match(segments []Segment, path string) (map[string]string, bool) {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(path[1:], "/")
|
||||
params := make(map[string]string)
|
||||
for i, current := range segments {
|
||||
if i >= len(parts) {
|
||||
return nil, false
|
||||
}
|
||||
if current.Wildcard {
|
||||
params[current.Name] = "/" + strings.Join(parts[i:], "/")
|
||||
return params, true
|
||||
}
|
||||
if current.Name != "" {
|
||||
if parts[i] == "" {
|
||||
return nil, false
|
||||
}
|
||||
params[current.Name] = parts[i]
|
||||
} else if current.Literal != parts[i] {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return params, len(segments) == len(parts)
|
||||
}
|
||||
45
transport/native/HTTP2.md
Normal file
45
transport/native/HTTP2.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# native HTTP/2 进入条件
|
||||
|
||||
当前 `transport/native` 是经过 Linux 长连接和反向代理验收的 HTTP/1.1 实现。HTTP/2 暂不直接打开,原因是它把一个 TCP 连接变成多个独立请求流,连接级的 Keep-Alive、超时和并发限制不能直接复用现有 HTTP/1.1 状态机。
|
||||
|
||||
当前已提供独立的 `transport/http2` 实验 Runner。它已经验证 TLS/h2、h2c、本地连接和请求闸门、GOAWAY 排空以及 RST_STREAM 取消;这些结果用于验证生命周期设计,不代表 native 已经启用 HTTP/2。实验 Runner 还覆盖了默认 1 MiB 头部上限、8 MiB 请求体上限、128 路同连接 stream 多路复用,以及 2 MiB 慢读取 body 在窗口耗尽后的恢复。`TestTLSSoak` 默认运行 3 秒,Linux 长测通过 `python3 scripts/verifyHTTP2.py --duration 30m` 启动。
|
||||
|
||||
2026-09-23 已在 Linux amd64 完成单 TLS 连接、128 个 worker 的 30 分钟长测:58,102,476 次请求,0 失败与协议错误,采样 RSS 峰值约 22.6 MiB,退出后连接、活动请求和 FD 回收。测试容器限制为 2 核、2 GiB,测量包含同一进程中的客户端与服务端,见 [报告](../../performance/http2SoakLinux.json)。该场景包含慢 Handler 与 64 KiB body。
|
||||
|
||||
同日完成多连接慢客户端混合场景:8 条独立 TLS 连接,每条 14 个普通请求 worker、1 个慢上传 worker、1 个慢下载 worker,持续 30 分钟。256 KiB 上传每 4 KiB 等待 2 ms;8 MiB 下载每次最多读取 64 KiB 后等待 20 ms。共完成 53,191,687 次请求,其中慢上传 106,971 次、慢下载 5,588 次,0 失败与协议错误;每条连接均覆盖三类请求且没有重连。采样 RSS 峰值约 116.6 MiB,包含客户端、服务端与下载缓冲;退出后活动连接/请求归零、goroutine 回到 2、FD 回到 6,释放测试数据并两次 GC 后存活堆约 450 KiB。运行命令为 `python3 scripts/verifyHTTP2.py --scenario mixed --duration 30m`,完整采样见 [混合长测报告](../../performance/http2MixedSoakLinux.json)。8 MiB 下载超过客户端的 stream 初始窗口,需要窗口更新,但不代表耗尽了连接级窗口;后者由下述独立帧测试验证。Nginx HTTP/2 回源联调结果见下方。
|
||||
|
||||
连接级流控及关闭边界测试已通过 Linux amd64 的 HTTP/2 全包 race、vet、build 回归,测试提交、执行命令和原始日志校验值见 [边界回归报告](../../performance/http2FlowControlLinux.json)。
|
||||
|
||||
Nginx 1.30.5 的真实 HTTP/2 回源联调也已通过:普通与 race 模式各 3 轮,覆盖两段 TLS/h2、证书拒绝、连接复用、超时/取消恢复和 GOAWAY 下的新旧实例切换。完整配置边界、运行方法与报告见 [HTTP/2 代理回源验收](../http2/PROXY.md)。
|
||||
|
||||
## 目标边界
|
||||
|
||||
- HTTP/2 必须作为显式 opt-in 协议,不改变 `omni.New()`、`transport/nethttp` 或 native HTTP/1.1 的默认行为。
|
||||
- TLS ALPN 只在 HTTP/2 实现通过全部验收后增加 `h2`;当前 native 只宣告 `http/1.1`,误配 `h2` 会在启动时失败。
|
||||
- Handler、Service、统一响应、requestId、可信代理和业务日志继续复用现有 `httpx.Context` 与 `http.Handler` 边界。
|
||||
- h2c 只用于本地测试,生产入口使用 TLS + ALPN;不能把明文升级路径默认为公网协议。
|
||||
|
||||
## 连接和流的生命周期
|
||||
|
||||
1. accept loop 先执行连接上限检查,再执行 TLS 握手;握手受独立超时约束,不能占满连接槽位后无限等待。
|
||||
2. TLS 协商得到 `h2` 后进入 HTTP/2 connection state;其他 ALPN 直接关闭,不能把 HTTP/2 preface 交给 HTTP/1.1 解析器。
|
||||
3. 每个 stream 创建独立的 `http.Request` 和取消 context。`RST_STREAM` 只取消当前请求,TCP 断开才取消该连接上的所有请求。
|
||||
4. `MaxConcurrentRequests` 是所有连接共享的全局请求闸门;`MaxConcurrentStreams` 限制单连接并发流;两者都必须在收到完整请求体前生效。
|
||||
5. `MaxHeaderBytes` 沿用标准库默认 1 MiB 上限,`MaxBodyBytes` 默认限制请求体为 8 MiB;已声明超限的请求在进入业务 handler 前返回 413,未声明长度的 body 通过受限 reader 读取,避免业务层无意间把整个请求体加载到内存。
|
||||
6. 服务关闭时停止 accept,发送 GOAWAY 拒绝新流,等待活动流排空;超过 `ShutdownTimeout` 后关闭连接并取消剩余请求。实验 Runner 已覆盖这条顺序。
|
||||
|
||||
## 必须可观测的状态
|
||||
|
||||
HTTP/2 版本需要在 native `Stats` 之外至少记录:活动 stream、被拒绝 stream、连接级 protocol error、RST_STREAM、GOAWAY 发送次数,以及因流控或并发闸门触发的拒绝。实验 Runner 当前已通过 `ActiveRequests`、`RejectedRequests`、`ProtocolErrors`、`RSTStreams`、`GoAwaySent` 和 `GoAwayFrames` 暴露活动/拒绝/协议错误/客户端重置/优雅关闭快照;帧观察器只解析 9 字节头部并跳过 payload,不会缓存大 body。计数必须是原子快照,不能在 metrics handler 中锁住连接状态机。
|
||||
|
||||
## 进入实现前的验收矩阵
|
||||
|
||||
- TLS ALPN:只协商 `h2`,HTTP/1.1 客户端仍由现有 native 路径处理;不支持的 ALPN 在握手前后都不会误入错误解析器。
|
||||
- 多路复用:一个连接并发至少 128 个 stream,响应和 requestId 不串线,在流与连接级发送额度充足时,单个慢 stream 不阻塞其他可发送 stream(实验 Runner 已覆盖 128 路同连接 stream)。
|
||||
- 流控:慢读取、慢写、窗口耗尽和大 body 都能恢复;内存不会随未消费 body 无限增长。实验 Runner 已覆盖 2 MiB 慢读取 body 和请求体上限,以及客户端将 stream 窗口设为零时的大响应阻塞、同连接 204、其他连接带 body 响应、WINDOW_UPDATE 恢复发送和 RST_STREAM 取消回收;连接级独立测试将初始 stream 窗口设为 128 KiB,仅消耗默认 65,535 字节连接窗口,确认暂停与隔离,连接级 WINDOW_UPDATE 后恢复并复用连接;持续慢客户端混合长测也已通过。连接级窗口耗尽时,同连接的其他 DATA 也受该窗口约束,HEADERS 和其他 TCP 连接仍可继续。
|
||||
- 取消:RST_STREAM、客户端断开、服务关闭分别只影响应受影响的请求,并能在 metrics 中看到回收;当前实验 Runner 已覆盖 RST_STREAM 与服务关闭,包括客户端零接收窗口造成写阻塞时,关闭超时强制解除写阻塞、取消请求并回收连接。
|
||||
- 背压:连接上限、全局 stream 上限、单连接 stream 上限和 header/body 上限均能快速拒绝并自动恢复。实验 Runner 已增加 TLS 未完成握手占满连接槽位时的及时拒绝、握手超时回收及后续正常 HTTPS 请求恢复验收。
|
||||
- 代理联调:Nginx 的 TLS/h2 回源、Keep-Alive、超时/取消及 GOAWAY 排空已由 `verifyHTTP2Proxy.py` 验证。旧实例处理中的 POST 能正常完成,新请求转入备用实例,旧实例连接与请求归零。此测试使用单进程 Nginx,未验证 Nginx master/worker 重载或 Cloudflare 实网部署。`verifyProxy.py` 继续承担 native HTTP/1.1 TLS 回源验收。
|
||||
- 长测:Linux amd64 至少 30 分钟 TLS、多连接、多 stream、慢客户端混合场景;要求 0 协议错误、0 请求串线,连接、stream、goroutine 和 FD 在停止后回收。单连接场景与 8 连接慢上传/慢下载混合场景均已通过,原始日志与采样分别保存在两个报告中。
|
||||
|
||||
这些实验结果已经覆盖到 Nginx HTTP/2 回源联调。下一步是统一启动配置、native 的 HTTP/1.1 与 HTTP/2 协议接入回归,以及业务环境试用,不能仅通过添加 `h2` 到 native 的 TLS ALPN 就宣告接入完成。当前继续保持显式选择实验适配器;默认 native 路径仍为 HTTP/1.1,需要成熟 HTTP/2 入口时可使用 `transport/nethttp` 或代理终止后回源 HTTP/1.1。
|
||||
104
transport/native/disconnect_test.go
Normal file
104
transport/native/disconnect_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
1326
transport/native/server.go
Normal file
1326
transport/native/server.go
Normal file
File diff suppressed because it is too large
Load Diff
1194
transport/native/server_test.go
Normal file
1194
transport/native/server_test.go
Normal file
File diff suppressed because it is too large
Load Diff
74
transport/native/slow_client_test.go
Normal file
74
transport/native/slow_client_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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, r *http.Request) {
|
||||
if _, err := io.ReadAll(r.Body); err == nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user