Compare commits
68 Commits
v0.1.0
...
467c890363
| Author | SHA1 | Date | |
|---|---|---|---|
| 467c890363 | |||
| be620d2243 | |||
| 8e18528fc2 | |||
| 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 | |||
| ff95692edb | |||
| 0bd71b17b4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ bin/
|
|||||||
dist/
|
dist/
|
||||||
*.log
|
*.log
|
||||||
coverage.out
|
coverage.out
|
||||||
|
__pycache__/
|
||||||
|
|||||||
96
CHANGELOG.md
Normal file
96
CHANGELOG.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# 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` 指定完整版本号。
|
||||||
|
- 显式远程版本禁用自动本地替换;本地替换路径支持空格,并按精确 module path 识别框架目录。
|
||||||
|
- 生成项目通过 `signal.NotifyContext` 接入优雅退出,启动过程中的错误会先执行 defer 清理再报告。
|
||||||
|
- `App.Run` 自动处理 `SIGINT` / `SIGTERM`;`RunContext` 仍由调用方控制,退出超时会关闭活跃连接。
|
||||||
|
- 增加请求排空、超时取消、启动失败清理测试,以及远程脚手架和真实进程信号验收脚本。
|
||||||
|
|
||||||
|
## v0.1.0
|
||||||
|
|
||||||
|
- 首个可试用版本:应用生命周期、HTTP 抽象、Gin 与标准库路由适配器、统一响应与错误。
|
||||||
|
- 提供配置、日志、校验、认证接口、数据库基础能力、健康探针和项目脚手架。
|
||||||
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 决定是否优化对象分配或事件状态机。
|
||||||
154
README.md
154
README.md
@@ -1,13 +1,32 @@
|
|||||||
# GoOmni
|
# GoOmni
|
||||||
|
|
||||||
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gin 作为网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
|
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用只依赖标准库的网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
|
||||||
|
|
||||||
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
|
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.1.0
|
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.6.0-preview
|
||||||
|
go-omni new --module example.com/billing ./billing
|
||||||
|
cd billing
|
||||||
|
go mod tidy
|
||||||
|
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
|
```text
|
||||||
@@ -19,13 +38,16 @@ go-omni/
|
|||||||
├── health/ # 存活和就绪检查
|
├── health/ # 存活和就绪检查
|
||||||
├── middleware/ # 请求 ID 等基础中间件
|
├── middleware/ # 请求 ID 等基础中间件
|
||||||
├── logx/ # 业务层结构化日志
|
├── logx/ # 业务层结构化日志
|
||||||
|
├── metrics/ # 请求指标与状态统计
|
||||||
├── config/ # 配置管理
|
├── config/ # 配置管理
|
||||||
├── validate/ # 参数校验
|
├── validate/ # 参数校验
|
||||||
├── auth/ # 认证和权限接口
|
├── auth/ # 认证和权限接口
|
||||||
├── database/ # database/sql 生命周期和事务
|
├── database/ # database/sql 生命周期和事务
|
||||||
├── transport/gin/ # Gin 网络适配器,可替换
|
├── transport/nethttp/ # 标准库网络适配器
|
||||||
|
├── transport/native/ # 自研 HTTP/1.1 服务端预览
|
||||||
|
├── transport/http2/ # opt-in HTTP/2 实验适配器
|
||||||
├── examples/ # 示例项目
|
├── examples/ # 示例项目
|
||||||
└── cmd/ # 后续 CLI
|
└── cmd/ # go-omni 项目脚手架
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -63,17 +85,53 @@ func main() {
|
|||||||
return omni.NotFound("USER_NOT_FOUND", "user was not found")
|
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
|
```go
|
||||||
application := omni.NewWithRouter(myRouter)
|
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
|
```go
|
||||||
func (s *UserService) Create(ctx *httpx.Context) error {
|
func (s *UserService) Create(ctx *httpx.Context) error {
|
||||||
@@ -82,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/文本格式、级别、服务名和输出文件。
|
`WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。
|
||||||
|
|
||||||
应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`:
|
应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`:
|
||||||
@@ -101,10 +169,42 @@ application.OnShutdown(logger.Close)
|
|||||||
|
|
||||||
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
|
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
|
||||||
`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、
|
`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_*` 加载。
|
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
|
||||||
|
|
||||||
`App` 同时实现标准 `http.Handler`,并提供 `RunContext`:收到 `SIGINT` / `SIGTERM` 后可以先停止接收新请求,再在超时时间内等待活跃请求完成。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
|
网络层也由同一份配置选择,Handler / Service / Repository 不需要导入具体适配器:
|
||||||
|
|
||||||
|
| 配置 | 默认值 / 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GO_OMNI_TRANSPORT` | `nethttp`;可选 `native`、`http2` |
|
||||||
|
| `GO_OMNI_TLS_CERT_FILE` | TLS 证书 PEM 文件路径 |
|
||||||
|
| `GO_OMNI_TLS_KEY_FILE` | TLS 私钥 PEM 文件路径,必须与证书一起配置 |
|
||||||
|
| `GO_OMNI_SHUTDOWN_TIMEOUT` | `10s`,三种适配器共用的请求排空时间 |
|
||||||
|
|
||||||
|
例如运行自研 HTTP/1.1,或显式试用独立 HTTP/2 适配器:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GO_OMNI_TRANSPORT=native go run ./examples/reference
|
||||||
|
GO_OMNI_TRANSPORT=http2 GO_OMNI_HTTP_ADDR=127.0.0.1:8443 \
|
||||||
|
GO_OMNI_TLS_CERT_FILE=./server.crt GO_OMNI_TLS_KEY_FILE=./server.key \
|
||||||
|
go run ./examples/reference
|
||||||
|
```
|
||||||
|
|
||||||
|
`http2` 必须配置 TLS;native TLS 只协商 `http/1.1`,nethttp TLS 支持标准库的 HTTP/1.1 和 HTTP/2。
|
||||||
|
未知适配器、缺少配对证书、无法加载证书时,`Run` / `RunContext` 在监听前返回错误并执行资源清理,不会退回其他协议。
|
||||||
|
HTTP/2 仍为实验适配器,尚未合并到 native 多协议监听器。
|
||||||
|
|
||||||
|
代码配置可写成 `omni.New(omni.WithTransport(omni.TransportNative), omni.WithTLS(certFile, keyFile))`。
|
||||||
|
`ServeContext(ctx, listener)` 使用相同配置并接管已有 listener;失败或退出时也会关闭它。
|
||||||
|
`RunWith(ctx, runner)` 保留自定义网络层入口,由 runner 自己管理 TLS、超时和网络参数;连接/请求上限等适配器专属选项仍通过这个入口设置。
|
||||||
|
`NewServer` 仅创建标准库明文服务器,配置选择应用于 `Run`、`RunContext` 和 `ServeContext`。
|
||||||
|
三种适配器的配置、真实 TLS 业务和关闭行为已通过 Linux 全量 race 及 10 次重复回归,见 [统一启动验收](performance/transportStartup.md)。
|
||||||
|
|
||||||
|
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。
|
||||||
|
退出时先停止接收新请求,等待活跃请求完成,再执行资源清理。超过 `ShutdownTimeout` 时强制关闭连接、取消请求 context,并返回超时错误。业务代码应响应 context 取消;框架无法强制结束忽略取消的业务 goroutine。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
|
||||||
|
|
||||||
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
|
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
|
||||||
|
|
||||||
@@ -134,7 +234,11 @@ application.GET("/health/ready", checker.ReadinessHandler())
|
|||||||
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler:
|
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
services := servicecontext.New(logger, users)
|
services, err := servicecontext.New(ctx, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer services.Close()
|
||||||
userService := service.NewUsers(services)
|
userService := service.NewUsers(services)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,6 +246,28 @@ userService := service.NewUsers(services)
|
|||||||
|
|
||||||
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
|
[`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
|
```sh
|
||||||
@@ -150,8 +276,10 @@ go run ./examples/reference
|
|||||||
|
|
||||||
默认地址为 `http://127.0.0.1:8080`。示例文档提供完整的 curl 操作流程。数据只保存在内存中,重启后清空。
|
默认地址为 `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` 注册,无需导入网络库。
|
||||||
|
|
||||||
## 当前边界
|
## 当前边界
|
||||||
|
|
||||||
缓存、消息队列、定时任务和 CLI 暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
|
缓存、消息队列和定时任务暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
|
||||||
|
|
||||||
|
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。
|
||||||
|
|||||||
72
ROADMAP.md
Normal file
72
ROADMAP.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# GoOmni 成熟度计划
|
||||||
|
|
||||||
|
## 1. 新项目启动与发布流程(已完成,v0.1.1)
|
||||||
|
|
||||||
|
- 修正脚手架固定版本引用,保留显式本地开发选项。
|
||||||
|
- 生成服务处理退出信号,等待请求完成后清理资源。
|
||||||
|
- 验收:源码目录外安装远程 CLI → 生成无 replace 的项目 → 编译 → 请求健康接口和业务接口 → SIGINT/SIGTERM 正常退出。
|
||||||
|
- 自动验收入口:`python3 scripts/verifyScaffold.py --version v0.1.1`。
|
||||||
|
- 已通过全量 race 测试、vet、build,以及从远程提交安装 CLI 后的无 replace 端到端验收;覆盖生成服务和 `App.Run` 的两种退出信号。
|
||||||
|
|
||||||
|
## 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. 网络适配一致性(已完成,v0.4.0)
|
||||||
|
|
||||||
|
- 标准库适配器运行公共契约测试,并为自研网络层保留相同的接入边界。
|
||||||
|
- 覆盖路由参数、分组、中间件、错误响应和取消传播。
|
||||||
|
- 验收:切换适配器后,业务 Handler 和 Service 无需修改,约定的 HTTP 行为一致。
|
||||||
|
- 新增公共 `transport/transporttest.Run`,自研适配器可直接复用;覆盖静态 / 参数 / 通配路由、分组、405 Allow、鉴权短路、错误与 panic、响应提交、requestId、并发隔离、截止时间及真实客户端取消。
|
||||||
|
- 标准库路由实现明确的路径、方法、尾部斜杠、通配参数、Any 和 404/405 约定;不再引入 Gin 依赖。
|
||||||
|
- 完整用户 CRUD 已用标准库适配器验证;自研适配器可直接复用同一公共契约测试包。
|
||||||
|
- 自动验收入口:`python3 scripts/verifyTransports.py --version v0.4.0`,从源码目录外消费远程版本与公共契约测试包。
|
||||||
|
|
||||||
|
## 4. 实际业务试用(已完成,v0.4.0)
|
||||||
|
|
||||||
|
- 在独立业务目录生成 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 个业务容器仍运行。当前结果不覆盖 Nginx master/worker 重载或 Cloudflare 实网部署。
|
||||||
|
- 已接入 `omni.New` 的统一 transport / TLS / shutdownTimeout 配置:默认 nethttp,可显式选择 native 或实验 HTTP/2;证书在监听前加载,HTTP/2 配置必须启用 TLS。`RunWith` 继续支持自定义网络层,业务不依赖具体适配器。
|
||||||
|
- 参考业务已增加五种真实 TCP/TLS 组合的 CRUD、认证权限、错误响应、requestId 日志与协议协商测试;三种适配器共用排空和超时退出测试。修复 native 退出时过早取消在途请求,并使关闭超时错误返回调用方。下一步仍为业务环境试用、native 多协议监听器设计与接入回归,HTTP/2 保持实验状态。
|
||||||
|
- 统一启动已通过 Linux 全仓库 race/vet/build 及关键场景 10 次重复验证;修复了重复测试发现的 native 活动连接转为空闲时错过关闭的竞态,并保证不响应取消的 Handler 不阻塞关闭超时返回。脚手架独立项目的 native SIGINT/SIGTERM 验证通过;失败复现与最终验收证据见 [统一启动验收](performance/transportStartup.md)。
|
||||||
|
- 已用 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、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。
|
||||||
105
app/app.go
105
app/app.go
@@ -3,32 +3,44 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
"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
|
// App is the top-level GoOmni application. Its public behavior depends on
|
||||||
// httpx.Router, leaving the network implementation replaceable.
|
// httpx.Router, leaving the network implementation replaceable.
|
||||||
type App struct {
|
type App struct {
|
||||||
router httpx.Router
|
router httpx.Router
|
||||||
config config.Config
|
config config.Config
|
||||||
middlewares []httpx.Middleware
|
middlewares []httpx.Middleware
|
||||||
closers []func() error
|
closers []func() error
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
closeErr error
|
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. App.RunContext selects a configured built-in server; 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 {
|
func New(options ...config.Option) *App {
|
||||||
settings := configure(options...)
|
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
|
// NewWithRouter creates an application with a custom network layer. A custom
|
||||||
@@ -52,19 +64,24 @@ func configure(options ...config.Option) config.Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newApp(router httpx.Router, settings config.Config) *App {
|
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)}
|
middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)}
|
||||||
if settings.EnableLogger {
|
if settings.EnableLogger {
|
||||||
middlewares = append(middlewares, middleware.AccessLog())
|
middlewares = append(middlewares, middleware.AccessLog())
|
||||||
}
|
}
|
||||||
middlewares = append(middlewares, middleware.Recovery())
|
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.
|
// Router returns the transport contract used by this application.
|
||||||
func (a *App) Router() httpx.Router { return a.router }
|
func (a *App) Router() httpx.Router { return a.router }
|
||||||
|
|
||||||
// Engine is kept as a compatibility name for the old API. It returns a
|
// 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 }
|
func (a *App) Engine() http.Handler { return a.router }
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
||||||
@@ -73,7 +90,11 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Config returns the effective application configuration.
|
// 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.
|
// Use adds middleware to routes registered after this call.
|
||||||
func (a *App) Use(middleware ...httpx.Middleware) *App {
|
func (a *App) Use(middleware ...httpx.Middleware) *App {
|
||||||
@@ -104,12 +125,16 @@ func (a *App) Close() error {
|
|||||||
return a.closeErr
|
return a.closeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the HTTP server. The default address is :8080.
|
// Run starts the HTTP server and handles SIGINT/SIGTERM. Use RunContext when
|
||||||
|
// the caller owns cancellation. The default address is :8080.
|
||||||
func (a *App) Run(address ...string) error {
|
func (a *App) Run(address ...string) error {
|
||||||
return a.RunContext(context.Background(), address...)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
return a.RunContext(ctx, address...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a standard HTTP server around the application.
|
// NewServer creates a plain standard HTTP server around the application.
|
||||||
|
// Use RunContext or ServeContext to apply the configured transport and TLS.
|
||||||
func (a *App) NewServer(address string) *http.Server {
|
func (a *App) NewServer(address string) *http.Server {
|
||||||
if address == "" {
|
if address == "" {
|
||||||
address = a.config.Address
|
address = a.config.Address
|
||||||
@@ -125,6 +150,38 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
|||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return a.Close()
|
||||||
|
}
|
||||||
|
serve, err := a.prepareTransport()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Join(err, a.Close())
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("tcp", a.listenAddress(address...))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Join(err, a.Close())
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
return errors.Join(serve(ctx, listener), a.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWith starts a custom server runner while keeping App-owned cleanup in the
|
||||||
|
// same lifecycle. The runner receives the transport-neutral App handler and
|
||||||
|
// owns transport/TLS settings; built-in transport selection is bypassed.
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return a.Close()
|
||||||
|
}
|
||||||
|
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) listenAddress(address ...string) string {
|
||||||
listenOn := a.config.Address
|
listenOn := a.config.Address
|
||||||
if listenOn == "" {
|
if listenOn == "" {
|
||||||
listenOn = ":8080"
|
listenOn = ":8080"
|
||||||
@@ -132,9 +189,18 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
|||||||
if len(address) > 0 && address[0] != "" {
|
if len(address) > 0 && address[0] != "" {
|
||||||
listenOn = address[0]
|
listenOn = address[0]
|
||||||
}
|
}
|
||||||
server := a.NewServer(listenOn)
|
return listenOn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error {
|
||||||
serveErr := make(chan error, 1)
|
serveErr := make(chan error, 1)
|
||||||
go func() { serveErr <- server.ListenAndServe() }()
|
go func() {
|
||||||
|
if server.TLSConfig != nil {
|
||||||
|
serveErr <- server.ServeTLS(listener, "", "")
|
||||||
|
} else {
|
||||||
|
serveErr <- server.Serve(listener)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-serveErr:
|
case err := <-serveErr:
|
||||||
@@ -151,6 +217,11 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
|||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
shutdownErr := server.Shutdown(shutdownCtx)
|
shutdownErr := server.Shutdown(shutdownCtx)
|
||||||
|
if shutdownErr != nil {
|
||||||
|
// Shutdown leaves active connections open on timeout. Close them so
|
||||||
|
// request contexts are canceled before releasing shared resources.
|
||||||
|
shutdownErr = errors.Join(shutdownErr, server.Close())
|
||||||
|
}
|
||||||
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
||||||
shutdownErr = errors.Join(shutdownErr, err)
|
shutdownErr = errors.Join(shutdownErr, err)
|
||||||
}
|
}
|
||||||
|
|||||||
163
app/lifecycle_test.go
Normal file
163
app/lifecycle_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type closingListener struct {
|
||||||
|
net.Listener
|
||||||
|
closed chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *closingListener) Close() error {
|
||||||
|
err := l.Listener.Close()
|
||||||
|
l.once.Do(func() { close(l.closed) })
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTestServer(t *testing.T, application *App) (*closingListener, context.CancelFunc, <-chan error) {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wrapped := &closingListener{Listener: listener, closed: make(chan struct{})}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- application.serveContext(ctx, application.NewServer(""), wrapped) }()
|
||||||
|
return wrapped, cancel, done
|
||||||
|
}
|
||||||
|
|
||||||
|
func await(t *testing.T, event <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-event:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for server event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShutdownDrainsRequestBeforeCleanup(t *testing.T) {
|
||||||
|
application := NewWithRouter(nethttp.New(), config.WithShutdownTimeout(3*time.Second))
|
||||||
|
started, release, cleaned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||||
|
t.Cleanup(unblock)
|
||||||
|
application.GET("/slow", func(ctx *httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
return ctx.OK("finished")
|
||||||
|
})
|
||||||
|
application.OnShutdown(func() error { close(cleaned); return nil })
|
||||||
|
listener, cancel, done := startTestServer(t, application)
|
||||||
|
requestDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
response, err := client.Get("http://" + listener.Addr().String() + "/slow")
|
||||||
|
if err == nil {
|
||||||
|
defer response.Body.Close()
|
||||||
|
_, err = io.ReadAll(response.Body)
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
err = errors.New("request was not completed successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestDone <- err
|
||||||
|
}()
|
||||||
|
await(t, started)
|
||||||
|
cancel()
|
||||||
|
await(t, listener.closed)
|
||||||
|
select {
|
||||||
|
case <-cleaned:
|
||||||
|
t.Fatal("resources closed while a request was still running")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
if err := <-requestDone; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("server did not finish shutdown")
|
||||||
|
}
|
||||||
|
await(t, cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShutdownTimeoutClosesActiveConnection(t *testing.T) {
|
||||||
|
application := NewWithRouter(nethttp.New(), config.WithShutdownTimeout(25*time.Millisecond))
|
||||||
|
started, canceled := make(chan struct{}), make(chan struct{})
|
||||||
|
application.GET("/slow", func(ctx *httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-ctx.Context().Done()
|
||||||
|
close(canceled)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
listener, cancel, done := startTestServer(t, application)
|
||||||
|
requestDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(requestDone)
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
response, err := client.Get("http://" + listener.Addr().String() + "/slow")
|
||||||
|
if err == nil {
|
||||||
|
response.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
await(t, started)
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("expected shutdown deadline error, got %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("shutdown ignored timeout")
|
||||||
|
}
|
||||||
|
await(t, canceled)
|
||||||
|
await(t, requestDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunContextStartupFailureAndCanceledContextCloseResources(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
for _, canceled := range []bool{false, true} {
|
||||||
|
application := NewWithRouter(nethttp.New())
|
||||||
|
closeCount := 0
|
||||||
|
application.OnShutdown(func() error { closeCount++; return nil })
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if canceled {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
err := application.RunContext(ctx, listener.Addr().String())
|
||||||
|
cancel()
|
||||||
|
if !canceled && err == nil {
|
||||||
|
t.Fatal("expected occupied address error")
|
||||||
|
}
|
||||||
|
if canceled && err != nil {
|
||||||
|
t.Fatalf("canceled start: %v", err)
|
||||||
|
}
|
||||||
|
_ = application.Close()
|
||||||
|
if closeCount != 1 {
|
||||||
|
t.Fatalf("cleanup ran %d times", closeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
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()))
|
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
|
||||||
if err := wrapped(requestContext); err != nil {
|
if err := wrapped(requestContext); err != nil {
|
||||||
a.writeError(requestContext, err)
|
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) {
|
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
a.handle(a.router, http.MethodGet, path, handler, 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)
|
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) {
|
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
g.route(http.MethodGet, path, handler, middleware)
|
g.route(http.MethodGet, path, handler, middleware)
|
||||||
}
|
}
|
||||||
|
|||||||
79
app/transport.go
Normal file
79
app/transport.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeContext serves a caller-created listener using the configured transport
|
||||||
|
// and TLS. App takes ownership of the listener, including on startup failure,
|
||||||
|
// and runs resource cleanup after the server has stopped.
|
||||||
|
func (a *App) ServeContext(ctx context.Context, listener net.Listener) error {
|
||||||
|
if listener == nil {
|
||||||
|
return errors.Join(errors.New("go-omni: nil listener"), a.Close())
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
_ = listener.Close()
|
||||||
|
return a.Close()
|
||||||
|
}
|
||||||
|
serve, err := a.prepareTransport()
|
||||||
|
if err != nil {
|
||||||
|
_ = listener.Close()
|
||||||
|
return errors.Join(err, a.Close())
|
||||||
|
}
|
||||||
|
return errors.Join(serve(ctx, listener), a.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
|
||||||
|
if err := a.config.ValidateTransport(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
shutdownTimeout := a.config.ShutdownTimeout
|
||||||
|
if shutdownTimeout <= 0 {
|
||||||
|
shutdownTimeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
var tlsConfig *tls.Config
|
||||||
|
if strings.TrimSpace(a.config.TLS.CertFile) != "" {
|
||||||
|
certificate, err := tls.LoadX509KeyPair(a.config.TLS.CertFile, a.config.TLS.KeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("go-omni: load TLS certificate: %w", err)
|
||||||
|
}
|
||||||
|
tlsConfig = &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
|
switch a.config.Transport {
|
||||||
|
case config.TransportNative:
|
||||||
|
server := native.New(a)
|
||||||
|
server.ShutdownTimeout = shutdownTimeout
|
||||||
|
if tlsConfig != nil {
|
||||||
|
return func(ctx context.Context, listener net.Listener) error {
|
||||||
|
return server.ServeTLS(ctx, listener, tlsConfig)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return server.Serve, nil
|
||||||
|
case config.TransportHTTP2:
|
||||||
|
server := h2transport.New(a)
|
||||||
|
server.ShutdownTimeout = shutdownTimeout
|
||||||
|
return func(ctx context.Context, listener net.Listener) error {
|
||||||
|
return server.ServeTLS(ctx, listener, tlsConfig)
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
server := a.NewServer("")
|
||||||
|
server.TLSConfig = tlsConfig
|
||||||
|
return func(ctx context.Context, listener net.Listener) error {
|
||||||
|
return a.serveContext(ctx, server, listener)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
322
app/transport_test.go
Normal file
322
app/transport_test.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfiguredTransportLifecycle(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
for _, timeout := range []bool{false, true} {
|
||||||
|
name := transport + "/drain"
|
||||||
|
if timeout {
|
||||||
|
name = transport + "/timeout"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
|
limit := 3 * time.Second
|
||||||
|
if timeout {
|
||||||
|
limit = 80 * time.Millisecond
|
||||||
|
}
|
||||||
|
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(limit))
|
||||||
|
started, release, canceled := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
unblock := func() { once.Do(func() { close(release) }) }
|
||||||
|
defer unblock()
|
||||||
|
var cleaned atomic.Int32
|
||||||
|
application.OnShutdown(func() error { cleaned.Add(1); return nil })
|
||||||
|
application.GET("/slow", func(ctx *httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
select {
|
||||||
|
case <-ctx.Context().Done():
|
||||||
|
close(canceled)
|
||||||
|
return ctx.Context().Err()
|
||||||
|
case <-release:
|
||||||
|
return ctx.OK("finished")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wrapped := &closingListener{Listener: listener, closed: make(chan struct{})}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
defer listener.Close()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- application.ServeContext(ctx, wrapped) }()
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
requestDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
response, err := client.Get("https://" + listener.Addr().String() + "/slow")
|
||||||
|
if err == nil {
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, readErr := io.ReadAll(response.Body)
|
||||||
|
err = readErr
|
||||||
|
if response.StatusCode != 200 || !strings.Contains(string(body), "finished") {
|
||||||
|
err = errors.New("drained response was lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestDone <- err
|
||||||
|
}()
|
||||||
|
await(t, started)
|
||||||
|
began := time.Now()
|
||||||
|
cancel()
|
||||||
|
await(t, wrapped.closed)
|
||||||
|
if !timeout {
|
||||||
|
select {
|
||||||
|
case <-canceled:
|
||||||
|
t.Fatal("active request canceled before drain")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if cleaned.Load() != 0 {
|
||||||
|
t.Fatal("cleanup preceded active request")
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if timeout {
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || time.Since(began) < limit {
|
||||||
|
t.Fatalf("timeout ignored: %v after %v", err, time.Since(began))
|
||||||
|
}
|
||||||
|
await(t, canceled)
|
||||||
|
} else if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("server failed to stop")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-requestDone:
|
||||||
|
if !timeout && err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("client failed to finish")
|
||||||
|
}
|
||||||
|
_ = application.Close()
|
||||||
|
if cleaned.Load() != 1 {
|
||||||
|
t.Fatalf("cleanup count %d", cleaned.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredStartupFailureClosesResources(t *testing.T) {
|
||||||
|
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer occupied.Close()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, errorText string
|
||||||
|
options []config.Option
|
||||||
|
}{
|
||||||
|
{"unknown", "transport", []config.Option{config.WithTransport("unknown")}},
|
||||||
|
{"http2WithoutTLS", "tls", []config.Option{config.WithTransport("http2")}},
|
||||||
|
{"partialTLS", "tls", []config.Option{config.WithTLS("missing.crt", "")}},
|
||||||
|
{"nethttpCertificate", "load TLS certificate", []config.Option{config.WithTLS("missing.crt", "missing.key")}},
|
||||||
|
{"nativeCertificate", "load TLS certificate", []config.Option{config.WithTransport("native"), config.WithTLS("missing.crt", "missing.key")}},
|
||||||
|
{"http2Certificate", "load TLS certificate", []config.Option{config.WithTransport("http2"), config.WithTLS("missing.crt", "missing.key")}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
application := New(test.options...)
|
||||||
|
cleanupError := errors.New("cleanup failure")
|
||||||
|
count := 0
|
||||||
|
application.OnShutdown(func() error { count++; return cleanupError })
|
||||||
|
// The occupied address must not mask configuration/certificate errors.
|
||||||
|
err := application.RunContext(context.Background(), occupied.Addr().String())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.errorText) || !errors.Is(err, cleanupError) {
|
||||||
|
t.Fatalf("startup: %v", err)
|
||||||
|
}
|
||||||
|
_ = application.Close()
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("cleanup count %d", count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, canceled := range []bool{false, true} {
|
||||||
|
application := New(config.WithTransport("unknown"))
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if canceled {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
err = application.ServeContext(ctx, listener)
|
||||||
|
cancel()
|
||||||
|
if canceled && err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !canceled && err == nil {
|
||||||
|
t.Fatal("invalid transport accepted")
|
||||||
|
}
|
||||||
|
if _, err := listener.Accept(); !errors.Is(err, net.ErrClosed) {
|
||||||
|
t.Fatalf("listener leaked: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunContextConfiguredTLS(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
|
reserved, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
address := reserved.Addr().String()
|
||||||
|
reserved.Close()
|
||||||
|
settings := config.Default()
|
||||||
|
settings.Address, settings.Transport = address, transport
|
||||||
|
settings.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
|
||||||
|
application := New(config.WithConfig(settings))
|
||||||
|
application.GET("/health", func(ctx *httpx.Context) error { return ctx.OK("ok") })
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- application.RunContext(ctx) }()
|
||||||
|
defer func() {
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Error("RunContext failed to stop")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
client := &http.Client{Timeout: time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for {
|
||||||
|
response, err := client.Get("https://" + address + "/health")
|
||||||
|
if err == nil {
|
||||||
|
body, readErr := io.ReadAll(response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
if readErr != nil || response.StatusCode != 200 || !strings.Contains(string(body), `"data":"ok"`) {
|
||||||
|
t.Fatalf("response %s: %v", body, readErr)
|
||||||
|
}
|
||||||
|
want := "h2"
|
||||||
|
if transport == config.TransportNative {
|
||||||
|
want = "http/1.1"
|
||||||
|
}
|
||||||
|
if response.TLS.NegotiatedProtocol != want || len(response.TLS.VerifiedChains) == 0 {
|
||||||
|
t.Fatalf("TLS negotiation: %+v", response.TLS)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type runnerFunc func(context.Context, http.Handler, string) error
|
||||||
|
|
||||||
|
func (f runnerFunc) Run(ctx context.Context, handler http.Handler, address string) error {
|
||||||
|
return f(ctx, handler, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithRemainsIndependentOfBuiltInTransport(t *testing.T) {
|
||||||
|
for _, canceled := range []bool{false, true} {
|
||||||
|
application := New(config.WithTransport("custom"), config.WithTLS("missing", "missing"))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if canceled {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
calls, cleanups := 0, 0
|
||||||
|
application.OnShutdown(func() error { cleanups++; return nil })
|
||||||
|
err := application.RunWith(ctx, runnerFunc(func(_ context.Context, handler http.Handler, address string) error {
|
||||||
|
calls++
|
||||||
|
if handler != application || address != "127.0.0.1:1234" {
|
||||||
|
t.Fatal("custom runner arguments changed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}), "127.0.0.1:1234")
|
||||||
|
cancel()
|
||||||
|
wantCalls := 1
|
||||||
|
if canceled {
|
||||||
|
wantCalls = 0
|
||||||
|
}
|
||||||
|
if err != nil || calls != wantCalls || cleanups != 1 {
|
||||||
|
t.Fatalf("RunWith: %v calls=%d cleanups=%d", err, calls, cleanups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
|
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond))
|
||||||
|
started := make(chan context.Context, 1)
|
||||||
|
release, finished := make(chan struct{}), make(chan struct{})
|
||||||
|
application.GET("/blocked", func(ctx *httpx.Context) error {
|
||||||
|
defer close(finished)
|
||||||
|
started <- ctx.Context()
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- application.ServeContext(ctx, listener) }()
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
requestDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(requestDone)
|
||||||
|
response, err := client.Get("https://" + listener.Addr().String() + "/blocked")
|
||||||
|
if err == nil {
|
||||||
|
response.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var requestContext context.Context
|
||||||
|
select {
|
||||||
|
case requestContext = <-started:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
close(release)
|
||||||
|
t.Fatal("handler did not start")
|
||||||
|
}
|
||||||
|
defer func() { close(release); await(t, finished) }()
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("shutdown: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("handler defeated shutdown deadline")
|
||||||
|
}
|
||||||
|
// net/http's HTTP/2 streams observe the closed socket asynchronously.
|
||||||
|
await(t, requestContext.Done())
|
||||||
|
await(t, requestDone)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app_test.go
43
app_test.go
@@ -8,12 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAppReturnsStandardResponse(t *testing.T) {
|
func TestAppReturnsStandardResponse(t *testing.T) {
|
||||||
app := New(WithMode(gin.TestMode))
|
app := New()
|
||||||
app.GET("/health", func(ctx *Context) error {
|
app.GET("/health", func(ctx *Context) error {
|
||||||
return ctx.OK(map[string]string{"status": "ok"})
|
return ctx.OK(map[string]string{"status": "ok"})
|
||||||
})
|
})
|
||||||
@@ -41,7 +39,7 @@ func TestAppReturnsStandardResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAppReturnsAPIError(t *testing.T) {
|
func TestAppReturnsAPIError(t *testing.T) {
|
||||||
app := New(WithMode(gin.TestMode))
|
app := New()
|
||||||
app.GET("/users/:id", func(ctx *Context) error {
|
app.GET("/users/:id", func(ctx *Context) error {
|
||||||
return NotFound("USER_NOT_FOUND", "user was not found")
|
return NotFound("USER_NOT_FOUND", "user was not found")
|
||||||
})
|
})
|
||||||
@@ -63,7 +61,7 @@ func TestAppReturnsAPIError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAppRecoversFromPanic(t *testing.T) {
|
func TestAppRecoversFromPanic(t *testing.T) {
|
||||||
app := New(WithMode(gin.TestMode))
|
app := New()
|
||||||
app.GET("/panic", func(ctx *Context) error {
|
app.GET("/panic", func(ctx *Context) error {
|
||||||
panic("boom")
|
panic("boom")
|
||||||
})
|
})
|
||||||
@@ -85,7 +83,7 @@ func TestAppRecoversFromPanic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAppRunContextShutsDownGracefully(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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- app.RunContext(ctx, "127.0.0.1:0") }()
|
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) {
|
func TestAppClosesResourcesInReverseOrder(t *testing.T) {
|
||||||
app := New(WithMode(gin.TestMode))
|
app := New()
|
||||||
var closed []string
|
var closed []string
|
||||||
app.OnShutdown(func() error {
|
app.OnShutdown(func() error {
|
||||||
closed = append(closed, "database")
|
closed = append(closed, "database")
|
||||||
@@ -130,3 +128,34 @@ func TestAppUsesConfiguredAddressWhenNoOverrideIsProvided(t *testing.T) {
|
|||||||
t.Fatalf("expected configured address %q, got %q", settings.Address, got)
|
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"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/auth"
|
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRequireAuthAndRole(t *testing.T) {
|
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"}}}
|
authenticator := auth.StaticAuthenticator{"good-token": {UserID: "user-1", Roles: []string{"user"}}}
|
||||||
private := app.Group("/private", auth.RequireAuth(authenticator))
|
private := app.Group("/private", auth.RequireAuth(authenticator))
|
||||||
private.GET("/me", func(ctx *omni.Context) error {
|
private.GET("/me", func(ctx *omni.Context) error {
|
||||||
@@ -62,7 +61,7 @@ func TestRequireAuthAndRole(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestOptionalAuth(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 {
|
app.GET("/public", func(ctx *omni.Context) error {
|
||||||
principal, authenticated := auth.FromContext(ctx)
|
principal, authenticated := auth.FromContext(ctx)
|
||||||
return ctx.OK(map[string]any{"authenticated": authenticated, "userId": principal.UserID})
|
return ctx.OK(map[string]any{"authenticated": authenticated, "userId": principal.UserID})
|
||||||
|
|||||||
@@ -1,11 +1,67 @@
|
|||||||
# go-omni CLI
|
# go-omni CLI
|
||||||
|
|
||||||
生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/go-omni new --module example.com/billing ./billing
|
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
|
||||||
|
go run ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
通过 `go install ...@版本` 安装的 CLI 从构建信息中读取版本,并把它固定到生成项目的
|
||||||
|
`go.mod`。不会使用不存在的 `v0.0.0`,也不会在普通业务目录中添加本地 `replace`。
|
||||||
|
|
||||||
|
需要选择其他已发布版本时,使用完整版本号:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go-omni new --framework-version v0.6.0-preview --module example.com/billing ./billing
|
||||||
|
```
|
||||||
|
|
||||||
|
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
|
||||||
|
源码构建的 CLI 使用代码中声明的目标发布版本;该版本未发布时请显式选择已发布版本,或用 `--framework-dir` 验证本地源码。
|
||||||
|
|
||||||
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
|
在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地
|
||||||
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
|
`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入
|
||||||
`--force` 才会覆盖脚手架文件。
|
`--force` 才会覆盖脚手架文件。
|
||||||
|
|
||||||
|
也可以在任意目录显式指定源码路径:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go-omni new --framework-dir /path/to/go-omni --module example.com/billing ./billing
|
||||||
|
```
|
||||||
|
|
||||||
|
默认生成内存存储版本;选择 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.6.0-preview
|
||||||
|
python3 scripts/verifyDatabase.py --version v0.6.0-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
||||||
|
编译、请求接口,并对真实二进制分别发送 `SIGINT` 和 `SIGTERM`。同时验证框架 `Run` 的
|
||||||
|
信号处理和清理回调。每个进程都必须退出码为 0、释放监听端口;临时项目和测试进程会被清理。
|
||||||
|
|
||||||
|
发布前验证当前源码:
|
||||||
|
|
||||||
|
```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 {
|
switch {
|
||||||
case arg == "--force":
|
case arg == "--force":
|
||||||
options.Force = true
|
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":
|
case arg == "--module":
|
||||||
if index+1 >= len(args) {
|
if index+1 >= len(args) {
|
||||||
return errors.New("--module requires a value")
|
return errors.New("--module requires a value")
|
||||||
@@ -55,6 +66,17 @@ func runNew(args []string) error {
|
|||||||
options.FrameworkDir = args[index]
|
options.FrameworkDir = args[index]
|
||||||
case strings.HasPrefix(arg, "--framework-dir="):
|
case strings.HasPrefix(arg, "--framework-dir="):
|
||||||
options.FrameworkDir = strings.TrimPrefix(arg, "--framework-dir=")
|
options.FrameworkDir = strings.TrimPrefix(arg, "--framework-dir=")
|
||||||
|
case arg == "--framework-version":
|
||||||
|
if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" {
|
||||||
|
return errors.New("--framework-version requires a value")
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
options.FrameworkVersion = args[index]
|
||||||
|
case strings.HasPrefix(arg, "--framework-version="):
|
||||||
|
options.FrameworkVersion = strings.TrimPrefix(arg, "--framework-version=")
|
||||||
|
if strings.TrimSpace(options.FrameworkVersion) == "" {
|
||||||
|
return errors.New("--framework-version requires a value")
|
||||||
|
}
|
||||||
case strings.HasPrefix(arg, "-"):
|
case strings.HasPrefix(arg, "-"):
|
||||||
return fmt.Errorf("unknown option %q", arg)
|
return fmt.Errorf("unknown option %q", arg)
|
||||||
case target == "":
|
case target == "":
|
||||||
@@ -82,4 +104,7 @@ func usage() {
|
|||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Usage:")
|
fmt.Fprintln(os.Stderr, "Usage:")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
|
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)")
|
||||||
}
|
}
|
||||||
|
|||||||
51
cmd/go-omni/main_test.go
Normal file
51
cmd/go-omni/main_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||||
|
for _, flag := range [][]string{{"--framework-version", "v0.1.0"}, {"--framework-version=v0.1.0"}} {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(project, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), "go-omni v0.1.0") || strings.Contains(string(content), "replace ") {
|
||||||
|
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsEmptyFrameworkVersion(t *testing.T) {
|
||||||
|
for _, args := range [][]string{{"--framework-version"}, {"--framework-version="}, {"--framework-version", " "}} {
|
||||||
|
if err := runNew(args); err == nil {
|
||||||
|
t.Fatalf("accepted missing framework version: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,10 +4,12 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go/format"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
)
|
)
|
||||||
@@ -17,10 +19,12 @@ var templateFiles embed.FS
|
|||||||
|
|
||||||
// Options controls project generation.
|
// Options controls project generation.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Dir string
|
Dir string
|
||||||
Module string
|
Module string
|
||||||
FrameworkDir string
|
FrameworkDir string
|
||||||
Force bool
|
FrameworkVersion string
|
||||||
|
Database string
|
||||||
|
Force bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var files = map[string]string{
|
var files = map[string]string{
|
||||||
@@ -28,6 +32,8 @@ var files = map[string]string{
|
|||||||
"README.md": "templates/README.md.tmpl",
|
"README.md": "templates/README.md.tmpl",
|
||||||
"cmd/server/main.go": "templates/cmd-server-main.go.tmpl",
|
"cmd/server/main.go": "templates/cmd-server-main.go.tmpl",
|
||||||
"internal/model/user.go": "templates/internal-model-user.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/repository/memory.go": "templates/internal-repository-memory.go.tmpl",
|
||||||
"internal/servicecontext/context.go": "templates/internal-servicecontext-context.go.tmpl",
|
"internal/servicecontext/context.go": "templates/internal-servicecontext-context.go.tmpl",
|
||||||
"internal/service/user.go": "templates/internal-service-user.go.tmpl",
|
"internal/service/user.go": "templates/internal-service-user.go.tmpl",
|
||||||
@@ -42,6 +48,20 @@ func Generate(options Options) error {
|
|||||||
if strings.TrimSpace(options.Module) == "" {
|
if strings.TrimSpace(options.Module) == "" {
|
||||||
return errors.New("module path is required")
|
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()
|
||||||
|
}
|
||||||
|
if !validFrameworkVersion(version) {
|
||||||
|
return fmt.Errorf("framework version %q must be a complete v0 or v1 semantic version", version)
|
||||||
|
}
|
||||||
|
|
||||||
directory, err := filepath.Abs(options.Dir)
|
directory, err := filepath.Abs(options.Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,20 +74,43 @@ func Generate(options Options) error {
|
|||||||
return fmt.Errorf("create project directory: %w", err)
|
return fmt.Errorf("create project directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An explicit remote version opts out of automatic local development mode.
|
||||||
|
replace := ""
|
||||||
|
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
|
||||||
|
replace = frameworkReplace(directory, options.FrameworkDir)
|
||||||
|
}
|
||||||
data := templateData{
|
data := templateData{
|
||||||
Module: options.Module,
|
Module: options.Module,
|
||||||
FrameworkReplace: frameworkReplace(directory, options.FrameworkDir),
|
FrameworkVersion: version,
|
||||||
|
FrameworkReplace: replace,
|
||||||
|
Database: database,
|
||||||
}
|
}
|
||||||
paths := make([]string, 0, len(files))
|
projectFiles := make(map[string]string, len(files)+3)
|
||||||
for path := range files {
|
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)
|
paths = append(paths, path)
|
||||||
}
|
}
|
||||||
sort.Strings(paths)
|
sort.Strings(paths)
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
content, err := render(files[path], data)
|
content, err := render(projectFiles[path], data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("render %s: %w", path, err)
|
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))
|
target := filepath.Join(directory, filepath.FromSlash(path))
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
|
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
|
||||||
@@ -88,7 +131,9 @@ func Generate(options Options) error {
|
|||||||
|
|
||||||
type templateData struct {
|
type templateData struct {
|
||||||
Module string
|
Module string
|
||||||
|
FrameworkVersion string
|
||||||
FrameworkReplace string
|
FrameworkReplace string
|
||||||
|
Database string
|
||||||
}
|
}
|
||||||
|
|
||||||
func render(name string, data templateData) (string, error) {
|
func render(name string, data templateData) (string, error) {
|
||||||
@@ -149,7 +194,7 @@ func frameworkReplace(projectDir, configured string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return filepath.ToSlash(relative)
|
return strconv.Quote(filepath.ToSlash(relative))
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalPath(path string) string {
|
func canonicalPath(path string) string {
|
||||||
@@ -172,8 +217,14 @@ func findFrameworkRoot() string {
|
|||||||
for {
|
for {
|
||||||
moduleFile := filepath.Join(directory, "go.mod")
|
moduleFile := filepath.Join(directory, "go.mod")
|
||||||
content, readErr := os.ReadFile(moduleFile)
|
content, readErr := os.ReadFile(moduleFile)
|
||||||
if readErr == nil && strings.Contains(string(content), "module git.yuntuo.hk/k2safe/go-omni") {
|
if readErr == nil {
|
||||||
return directory
|
for _, line := range strings.Split(string(content), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 && fields[0] == "module" && strings.Trim(fields[1], "\"") == frameworkModule {
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
parent := filepath.Dir(directory)
|
parent := filepath.Dir(directory)
|
||||||
if parent == directory {
|
if parent == directory {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package scaffold
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -37,7 +39,7 @@ func TestGenerateCreatesServiceLayout(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read generated server: %v", err)
|
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.Run(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) {
|
if !strings.Contains(string(mainContent), expected) {
|
||||||
t.Fatalf("generated server missing %q: %s", expected, mainContent)
|
t.Fatalf("generated server missing %q: %s", expected, mainContent)
|
||||||
}
|
}
|
||||||
@@ -54,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) {
|
func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
|
||||||
root := frameworkRoot(t)
|
root := frameworkRoot(t)
|
||||||
project := filepath.Join(t.TempDir(), "generated")
|
project := filepath.Join(t.TempDir(), "generated")
|
||||||
@@ -73,11 +104,59 @@ func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if replace != filepath.ToSlash(expected) {
|
if replace != strconv.Quote(filepath.ToSlash(expected)) {
|
||||||
t.Fatalf("expected canonical replace %q, got %q", filepath.ToSlash(expected), replace)
|
t.Fatalf("expected canonical replace %q, got %q", filepath.ToSlash(expected), replace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateRemoteProjectPinsVersion(t *testing.T) {
|
||||||
|
for _, version := range []string{"v0.1.0", "v1.2.3-rc.1", "v0.0.0-20260920140624-8b19aa79eea0"} {
|
||||||
|
t.Run(version, func(t *testing.T) {
|
||||||
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
|
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); 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), "require "+frameworkModule+" "+version+"\n") || strings.Contains(string(content), "replace ") {
|
||||||
|
t.Fatalf("expected pinned remote dependency without replace: %s", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRejectsInvalidVersionBeforeWriting(t *testing.T) {
|
||||||
|
for _, version := range []string{"main", "latest", "v1.2", "v01.2.3", "v2.0.0", "v0.1.0-01", "v0.1.0\nreplace bad => ./bad"} {
|
||||||
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
|
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err == nil {
|
||||||
|
t.Fatalf("accepted invalid version %q", version)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(project); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("invalid version %q created a project: %v", version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrameworkVersionUsesInstalledModuleMetadata(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
info *debug.BuildInfo
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{nil, defaultFrameworkVersion},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "(devel)"}}, defaultFrameworkVersion},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.1.0+dirty"}}, defaultFrameworkVersion},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: "example.com/unrelated", Version: "v1.2.3"}}, defaultFrameworkVersion},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.9.2", Sum: "h1:test"}}, "v0.9.2"},
|
||||||
|
{&debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.0.0-20260920140624-8b19aa79eea0", Sum: "h1:test"}}, "v0.0.0-20260920140624-8b19aa79eea0"},
|
||||||
|
} {
|
||||||
|
if got := frameworkVersion(test.info); got != test.want {
|
||||||
|
t.Fatalf("version = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func frameworkRoot(t *testing.T) string {
|
func frameworkRoot(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
directory, err := os.Getwd()
|
directory, err := os.Getwd()
|
||||||
|
|||||||
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
This service was generated with `go-omni new`.
|
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
|
```sh
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
@@ -16,7 +38,72 @@ GO_OMNI_ENABLE_LOGGER=true GO_OMNI_LOG_LEVEL=debug go run ./cmd/server
|
|||||||
`GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和
|
`GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和
|
||||||
`/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。
|
`/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。
|
||||||
|
|
||||||
The generated layout keeps HTTP handlers, business services, repositories,
|
服务使用 `signal.NotifyContext` 处理 `SIGINT` / `SIGTERM`。部署时构建并运行服务二进制,
|
||||||
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`.
|
```sh
|
||||||
|
go build -o server ./cmd/server
|
||||||
|
./server
|
||||||
|
```
|
||||||
|
|
||||||
|
`GO_OMNI_SHUTDOWN_TIMEOUT` 控制等待请求完成的时间,默认 `10s`。
|
||||||
|
|
||||||
|
使用支持统一网络配置的框架版本时,设置 `GO_OMNI_TRANSPORT=native` 即可切换为自研 HTTP/1.1;
|
||||||
|
默认 `nethttp`。`GO_OMNI_TLS_CERT_FILE` 和 `GO_OMNI_TLS_KEY_FILE` 配对启用 HTTPS。
|
||||||
|
实验版 HTTP/2 使用 `GO_OMNI_TRANSPORT=http2`,必须同时配置 TLS 证书和私钥。
|
||||||
|
业务 Handler / Service / Repository 不需要修改;三种适配器共用上述退出等待时间。
|
||||||
|
{{if eq .Database "mysql"}}
|
||||||
|
`/health/ready` 已注册数据库探针:连接失败返回 `503`,数据库恢复后重新返回 `200`。
|
||||||
|
`/health/live` 始终只检查服务存活。`GO_OMNI_DB_PING_TIMEOUT` 限制初始化检查和数据库探针的等待时间。
|
||||||
|
退出时先排空 HTTP 请求,再关闭数据库连接池,最后关闭日志。
|
||||||
|
|
||||||
|
数据库连接池参数:
|
||||||
|
|
||||||
|
```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`。
|
||||||
|
|||||||
@@ -1,49 +1,57 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"{{.Module}}/internal/handler"
|
"{{.Module}}/internal/handler"
|
||||||
"{{.Module}}/internal/repository"
|
|
||||||
"{{.Module}}/internal/service"
|
"{{.Module}}/internal/service"
|
||||||
"{{.Module}}/internal/servicecontext"
|
"{{.Module}}/internal/servicecontext"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
settings, err := config.LoadFromEnv("")
|
settings, err := config.LoadFromEnv("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(settings.Log.Service) == "" {
|
if strings.TrimSpace(settings.Log.Service) == "" {
|
||||||
settings.Log.Service = "{{.Module}}"
|
settings.Log.Service = "{{.Module}}"
|
||||||
}
|
}
|
||||||
logger, err := logx.New(settings.Log)
|
logger, err := logx.New(settings.Log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
|
defer logger.Close()
|
||||||
// Access logs and business logs share the same configured sink.
|
// Access logs and business logs share the same configured sink.
|
||||||
logx.SetDefault(logger)
|
logx.SetDefault(logger)
|
||||||
|
|
||||||
users := repository.NewMemoryUsers()
|
services, err := servicecontext.New(ctx, logger)
|
||||||
services := servicecontext.New(logger, users)
|
if err != nil { return err }
|
||||||
|
defer services.Close()
|
||||||
userService := service.NewUsers(services)
|
userService := service.NewUsers(services)
|
||||||
userHandler := handler.NewUsers(userService)
|
userHandler := handler.NewUsers(userService)
|
||||||
checker, err := health.New()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
application := omni.New(omni.WithConfig(settings))
|
application := omni.New(omni.WithConfig(settings))
|
||||||
application.OnShutdown(logger.Close)
|
application.OnShutdown(logger.Close)
|
||||||
application.GET("/health/live", checker.LivenessHandler())
|
application.OnShutdown(services.Close)
|
||||||
application.GET("/health/ready", checker.ReadinessHandler())
|
application.GET("/health/live", services.Health.LivenessHandler())
|
||||||
|
application.GET("/health/ready", services.Health.ReadinessHandler())
|
||||||
userHandler.RegisterRoutes(application)
|
userHandler.RegisterRoutes(application)
|
||||||
if err := application.Run(settings.Address); err != nil {
|
return application.RunContext(ctx, settings.Address)
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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") }
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ module {{.Module}}
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require git.yuntuo.hk/k2safe/go-omni v0.0.0
|
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}}
|
{{- if .FrameworkReplace}}
|
||||||
replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}}
|
replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
"git.yuntuo.hk/k2safe/go-omni"
|
||||||
"{{.Module}}/internal/model"
|
"{{.Module}}/internal/model"
|
||||||
"{{.Module}}/internal/service"
|
"{{.Module}}/internal/service"
|
||||||
)
|
)
|
||||||
@@ -12,20 +13,109 @@ type Users struct {
|
|||||||
service *service.Users
|
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) {
|
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 {
|
func (h *Users) Create(ctx *omni.Context) error {
|
||||||
var input model.UserInput
|
var body userBody
|
||||||
if err := ctx.BindJSON(&input); err != nil {
|
if err := ctx.BindJSON(&body); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
user, err := h.service.Create(ctx.Context(), input)
|
user, err := h.service.Create(ctx.Request().Context(), model.UserInput(body))
|
||||||
if err != nil {
|
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 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"
|
import "time"
|
||||||
|
|
||||||
|
// User is the business entity; it has no dependency on HTTP or a database driver.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -10,7 +11,15 @@ type User struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserInput is shared by creation and full replacement (PUT).
|
||||||
type UserInput struct {
|
type UserInput struct {
|
||||||
Name string `json:"name"`
|
Name string
|
||||||
Email string `json:"email"`
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"{{.Module}}/internal/model"
|
"{{.Module}}/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserRepository interface {
|
// MemoryUsers stores data only for this process. Replace it with a database
|
||||||
Create(context.Context, model.UserInput) (model.User, error)
|
// implementation of UserRepository when persistence is required.
|
||||||
}
|
|
||||||
|
|
||||||
type MemoryUsers struct {
|
type MemoryUsers struct {
|
||||||
mu sync.Mutex
|
mu sync.RWMutex
|
||||||
|
users map[int64]model.User
|
||||||
|
emails map[string]int64
|
||||||
nextID 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()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
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()
|
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.nextID++
|
||||||
r.users = append(r.users, user)
|
r.users[user.ID] = user
|
||||||
|
r.emails[user.Email] = user.ID
|
||||||
return user, nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"{{.Module}}/internal/model"
|
"{{.Module}}/internal/model"
|
||||||
"{{.Module}}/internal/servicecontext"
|
"{{.Module}}/internal/servicecontext"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Users struct {
|
type Users struct {
|
||||||
context *servicecontext.Context
|
services *servicecontext.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUsers(context *servicecontext.Context) *Users {
|
func NewUsers(services *servicecontext.Context) *Users {
|
||||||
return &Users{context: context}
|
return &Users{services: services}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||||
// Put business normalization and rules here.
|
input, err := normalize(input)
|
||||||
return s.context.Users.Create(ctx, 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
|
package servicecontext
|
||||||
|
|
||||||
import (
|
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"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"{{.Module}}/internal/repository"
|
"{{.Module}}/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -10,8 +16,51 @@ import (
|
|||||||
type Context struct {
|
type Context struct {
|
||||||
Logger *logx.Logger
|
Logger *logx.Logger
|
||||||
Users repository.UserRepository
|
Users repository.UserRepository
|
||||||
|
Health *health.Checker
|
||||||
|
{{if eq .Database "mysql"}}DB *database.DB{{end}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger *logx.Logger, users repository.UserRepository) *Context {
|
// New initializes the project's dependencies before the HTTP listener opens.
|
||||||
return &Context{Logger: logger, Users: users}
|
// 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;
|
||||||
31
cmd/go-omni/scaffold/version.go
Normal file
31
cmd/go-omni/scaffold/version.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package scaffold
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.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-]+)*)?$`)
|
||||||
|
|
||||||
|
func validFrameworkVersion(version string) bool {
|
||||||
|
return frameworkVersionPattern.MatchString(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentFrameworkVersion() string {
|
||||||
|
info, _ := debug.ReadBuildInfo()
|
||||||
|
return frameworkVersion(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func frameworkVersion(info *debug.BuildInfo) string {
|
||||||
|
if info != nil && info.Main.Path == frameworkModule && info.Main.Sum != "" && validFrameworkVersion(info.Main.Version) {
|
||||||
|
return info.Main.Version
|
||||||
|
}
|
||||||
|
return defaultFrameworkVersion
|
||||||
|
}
|
||||||
@@ -4,24 +4,40 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultRequestIDHeader = "X-Request-ID"
|
const defaultRequestIDHeader = "X-Request-ID"
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransportNetHTTP = "nethttp"
|
||||||
|
TransportNative = "native"
|
||||||
|
TransportHTTP2 = "http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLSConfig enables HTTPS when both certificate paths are provided.
|
||||||
|
type TLSConfig struct {
|
||||||
|
CertFile string `json:"certFile"`
|
||||||
|
KeyFile string `json:"keyFile"`
|
||||||
|
}
|
||||||
|
|
||||||
// Config controls the default runtime behavior of an App.
|
// Config controls the default runtime behavior of an App.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Mode string
|
Address string `json:"address"`
|
||||||
Address string
|
Transport string `json:"transport"`
|
||||||
RequestIDHeader string
|
TLS TLSConfig `json:"tls"`
|
||||||
EnableLogger bool
|
RequestIDHeader string `json:"requestIdHeader"`
|
||||||
ShutdownTimeout time.Duration
|
EnableLogger bool `json:"enableLogger"`
|
||||||
Log logx.Config
|
TrustedProxies []string `json:"trustedProxies"`
|
||||||
|
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||||
|
Log logx.Config `json:"log"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Address: ":8080",
|
Address: ":8080",
|
||||||
|
Transport: TransportNetHTTP,
|
||||||
RequestIDHeader: defaultRequestIDHeader,
|
RequestIDHeader: defaultRequestIDHeader,
|
||||||
ShutdownTimeout: 10 * time.Second,
|
ShutdownTimeout: 10 * time.Second,
|
||||||
Log: logx.Default(),
|
Log: logx.Default(),
|
||||||
@@ -39,6 +55,27 @@ func (c Config) Validate() error {
|
|||||||
if c.ShutdownTimeout <= 0 {
|
if c.ShutdownTimeout <= 0 {
|
||||||
return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"}
|
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 c.ValidateTransport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTransport checks built-in transport selection without opening files
|
||||||
|
// or sockets. Empty Transport preserves the standard net/http default.
|
||||||
|
func (c Config) ValidateTransport() error {
|
||||||
|
switch c.Transport {
|
||||||
|
case "", TransportNetHTTP, TransportNative, TransportHTTP2:
|
||||||
|
default:
|
||||||
|
return &ConfigError{Field: "transport", Message: "must be nethttp, native or http2"}
|
||||||
|
}
|
||||||
|
cert, key := strings.TrimSpace(c.TLS.CertFile), strings.TrimSpace(c.TLS.KeyFile)
|
||||||
|
if (cert == "") != (key == "") {
|
||||||
|
return &ConfigError{Field: "tls", Message: "requires both certFile and keyFile"}
|
||||||
|
}
|
||||||
|
if c.Transport == TransportHTTP2 && cert == "" {
|
||||||
|
return &ConfigError{Field: "tls", Message: "is required for the experimental http2 transport"}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +90,14 @@ func (e *ConfigError) Error() string { return "config " + e.Field + " " + e.Mess
|
|||||||
// Option changes an App's configuration.
|
// Option changes an App's configuration.
|
||||||
type Option func(*Config)
|
type Option func(*Config)
|
||||||
|
|
||||||
// WithMode sets the default transport's running mode.
|
// WithTransport selects a built-in server. HTTP/2 is experimental and requires TLS.
|
||||||
func WithMode(mode string) Option {
|
func WithTransport(name string) Option {
|
||||||
return func(cfg *Config) { cfg.Mode = mode }
|
return func(cfg *Config) { cfg.Transport = name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTLS enables HTTPS. Certificates are loaded before Run opens its listener.
|
||||||
|
func WithTLS(certFile, keyFile string) Option {
|
||||||
|
return func(cfg *Config) { cfg.TLS = TLSConfig{CertFile: certFile, KeyFile: keyFile} }
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithRequestIDHeader changes the header used to read and return request IDs.
|
// WithRequestIDHeader changes the header used to read and return request IDs.
|
||||||
@@ -72,10 +114,22 @@ func WithLogger(enabled bool) Option {
|
|||||||
return func(cfg *Config) { cfg.EnableLogger = enabled }
|
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
|
// WithConfig applies a complete configuration loaded from a file or
|
||||||
// environment. Later options can still override individual fields.
|
// environment. Later options can still override individual fields.
|
||||||
func WithConfig(settings Config) Option {
|
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.
|
// WithLogConfig configures the logger used by the application runtime.
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
|
|
||||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||||
for _, key := range []string{
|
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_TRANSPORT", "GO_OMNI_TLS_CERT_FILE", "GO_OMNI_TLS_KEY_FILE",
|
||||||
"GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT",
|
"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_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
|
||||||
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
|
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
|
||||||
@@ -20,7 +22,7 @@ func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if cfg.Address != ":8080" || cfg.RequestIDHeader != "X-Request-ID" || cfg.ShutdownTimeout != 10*time.Second {
|
if cfg.Address != ":8080" || cfg.Transport != TransportNetHTTP || cfg.TLS != (TLSConfig{}) || cfg.RequestIDHeader != "X-Request-ID" || cfg.ShutdownTimeout != 10*time.Second {
|
||||||
t.Fatalf("unexpected defaults: %+v", cfg)
|
t.Fatalf("unexpected defaults: %+v", cfg)
|
||||||
}
|
}
|
||||||
if cfg.EnableLogger || cfg.Log.Level != "info" || cfg.Log.Format != "json" || cfg.Log.Output != "stderr" {
|
if cfg.EnableLogger || cfg.Log.Level != "info" || cfg.Log.Format != "json" || cfg.Log.Output != "stderr" {
|
||||||
@@ -28,10 +30,48 @@ func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTransportEnvironment(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, transport, cert, key, errorField string
|
||||||
|
}{
|
||||||
|
{"default", "", "", "", ""},
|
||||||
|
{"native", "native", "", "", ""},
|
||||||
|
{"nativeTLS", "native", "server.crt", "server.key", ""},
|
||||||
|
{"http2", "http2", "server.crt", "server.key", ""},
|
||||||
|
{"unknown", "typo", "", "", "transport"},
|
||||||
|
{"certOnly", "nethttp", "server.crt", "", "tls"},
|
||||||
|
{"keyOnly", "native", "", "server.key", "tls"},
|
||||||
|
{"http2WithoutTLS", "http2", "", "", "tls"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Setenv("TRANSPORT_TEST_TRANSPORT", test.transport)
|
||||||
|
t.Setenv("TRANSPORT_TEST_TLS_CERT_FILE", test.cert)
|
||||||
|
t.Setenv("TRANSPORT_TEST_TLS_KEY_FILE", test.key)
|
||||||
|
cfg, err := LoadFromEnv("TRANSPORT_TEST_")
|
||||||
|
if test.errorField != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.errorField) {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := test.transport
|
||||||
|
if want == "" {
|
||||||
|
want = TransportNetHTTP
|
||||||
|
}
|
||||||
|
if cfg.Transport != want || cfg.TLS.CertFile != test.cert || cfg.TLS.KeyFile != test.key {
|
||||||
|
t.Fatalf("settings: %+v", cfg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadFromEnvOverridesSettings(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_HTTP_ADDR", "127.0.0.1:9090")
|
||||||
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
|
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_ENABLE_LOGGER", "true")
|
||||||
t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
|
t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
|
||||||
t.Setenv("GO_OMNI_LOG_FORMAT", "text")
|
t.Setenv("GO_OMNI_LOG_FORMAT", "text")
|
||||||
@@ -45,7 +85,7 @@ func TestLoadFromEnvOverridesSettings(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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 {
|
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 +106,11 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
|
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 := Default()
|
||||||
cfg.Address = ""
|
cfg.Address = ""
|
||||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
|
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
|
||||||
@@ -80,13 +125,17 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestWithConfigAndWithLogConfig(t *testing.T) {
|
func TestWithConfigAndWithLogConfig(t *testing.T) {
|
||||||
settings := Default()
|
settings := Default()
|
||||||
settings.Mode = "release"
|
|
||||||
settings.Log.Service = "orders"
|
settings.Log.Service = "orders"
|
||||||
|
settings.TrustedProxies = []string{"127.0.0.1"}
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
WithConfig(settings)(&cfg)
|
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)
|
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)
|
WithLogConfig(Default().Log)(&cfg)
|
||||||
if cfg.Log.Service != "" {
|
if cfg.Log.Service != "" {
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ func LoadFromEnv(prefix string) (Config, error) {
|
|||||||
prefix = defaultEnvPrefix
|
prefix = defaultEnvPrefix
|
||||||
}
|
}
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
cfg.Mode = envString(prefix+"MODE", cfg.Mode)
|
|
||||||
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
|
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
|
||||||
|
cfg.Transport = envString(prefix+"TRANSPORT", cfg.Transport)
|
||||||
|
cfg.TLS.CertFile = envString(prefix+"TLS_CERT_FILE", "")
|
||||||
|
cfg.TLS.KeyFile = envString(prefix+"TLS_KEY_FILE", "")
|
||||||
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
|
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.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
|
||||||
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
|
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
|
||||||
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)
|
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||||
- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
||||||
- `Health`:用于就绪检查。
|
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
|
||||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
|
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
|
||||||
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
|
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
|
||||||
|
|
||||||
@@ -18,7 +18,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
|
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
db, err := database.Open(context.Background(), cfg)
|
db, err := database.Open(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,3 +41,18 @@ GO_OMNI_DB_PING_TIMEOUT=5s
|
|||||||
```
|
```
|
||||||
|
|
||||||
`DSN` 必填,驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。
|
`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) {
|
func Open(ctx context.Context, cfg Config) (*DB, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -103,9 +106,9 @@ func Open(ctx context.Context, cfg Config) (*DB, error) {
|
|||||||
if cfg.MaxOpenConns > 0 {
|
if cfg.MaxOpenConns > 0 {
|
||||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||||
}
|
}
|
||||||
if cfg.MaxIdleConns > 0 {
|
// database/sql uses zero to disable idle connections; do not leave its
|
||||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
// default pool size in place when the caller explicitly chooses zero.
|
||||||
}
|
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||||
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||||
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
|
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
|
||||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
|
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 {
|
if db == nil || db.DB == nil {
|
||||||
return errors.New("database is not initialized")
|
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.
|
// 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。
|
调用路径:HTTP → Handler → Service → Repository。
|
||||||
|
|
||||||
- Handler 依赖 GoOmni,示例代码不导入 Gin,也不调用 `Engine()` / `Raw()`。
|
- Handler 依赖 GoOmni,示例代码不导入具体网络库,也不调用 `Engine()` / `Raw()`。
|
||||||
- Service、Repository、Model 不依赖 GoOmni 或 Gin,通过标准 `context.Context` 传递请求取消信号。
|
- Service、Repository、Model 不依赖 GoOmni 或网络库,通过标准 `context.Context` 传递请求取消信号。
|
||||||
- JSON 校验标签留在 Handler 的请求结构上;业务模型没有 HTTP 绑定标签。
|
- JSON 校验标签留在 Handler 的请求结构上;业务模型没有 HTTP 绑定标签。
|
||||||
- Service 返回业务错误,由 Handler 映射为 HTTP 错误;未知错误交给框架转换成通用 500 响应。
|
- Service 返回业务错误,由 Handler 映射为 HTTP 错误;未知错误交给框架转换成通用 500 响应。
|
||||||
- 通过 `application.Group("/api/v1", handler.RequestLog(logger))` 挂载自定义中间件,再注册 `/users` 子路由。日志记录请求 ID、耗时和返回错误;panic 由框架恢复中间件处理。
|
- 通过 `application.Group("/api/v1", handler.RequestLog(logger))` 挂载自定义中间件,再注册 `/users` 子路由。日志记录请求 ID、耗时和返回错误;panic 由框架恢复中间件处理。
|
||||||
@@ -156,4 +156,6 @@ go vet ./...
|
|||||||
go build ./...
|
go build ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`TestUserCRUD` 使用标准库适配器运行完整业务注册代码;同一套 Handler、Service、Repository 也可通过 `omni.NewWithRouter` 接入自研适配器。
|
||||||
|
|
||||||
测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。
|
测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,51 +21,73 @@ import (
|
|||||||
|
|
||||||
func newApp(users service.UserRepository, logger *logx.Logger, options ...omni.Option) *omni.App {
|
func newApp(users service.UserRepository, logger *logx.Logger, options ...omni.Option) *omni.App {
|
||||||
application := omni.New(options...)
|
application := omni.New(options...)
|
||||||
application.GET("/health", func(ctx *omni.Context) error {
|
registerRoutes(application, users, logger, nil, nil)
|
||||||
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)
|
|
||||||
return application
|
return application
|
||||||
}
|
}
|
||||||
|
|
||||||
func newProtectedApp(users service.UserRepository, logger *logx.Logger, authenticator auth.Authenticator, options ...omni.Option) *omni.App {
|
func newProtectedApp(users service.UserRepository, logger *logx.Logger, authenticator auth.Authenticator, options ...omni.Option) *omni.App {
|
||||||
application := omni.New(options...)
|
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 {
|
application.GET("/health", func(ctx *omni.Context) error {
|
||||||
return ctx.OK(map[string]string{"status": "ok"})
|
return ctx.OK(map[string]string{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
api := application.Group("/api/v1", handler.RequestLog(logger))
|
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)
|
handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, write, delete)
|
||||||
return application
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger, err := logx.New(logx.Config{
|
|
||||||
Service: "reference-api",
|
|
||||||
Environment: "local",
|
|
||||||
Format: "json",
|
|
||||||
Writer: os.Stdout,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer logger.Close()
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
if err := runReference(ctx); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceConfig() (omni.Config, error) {
|
||||||
|
settings, err := omni.LoadConfig("")
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
// Preserve the example's loopback default and legacy address variable.
|
||||||
|
if strings.TrimSpace(os.Getenv("GO_OMNI_HTTP_ADDR")) == "" {
|
||||||
|
settings.Address = strings.TrimSpace(os.Getenv("GO_OMNI_ADDR"))
|
||||||
|
if settings.Address == "" {
|
||||||
|
settings.Address = "127.0.0.1:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settings.Log.Service == "" {
|
||||||
|
settings.Log.Service = "reference-api"
|
||||||
|
}
|
||||||
|
if settings.Log.Environment == "" {
|
||||||
|
settings.Log.Environment = "local"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")) == "" {
|
||||||
|
settings.Log.Output = "stdout"
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runReference(ctx context.Context) error {
|
||||||
|
settings, err := referenceConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger, err := logx.New(settings.Log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
logx.SetDefault(logger)
|
||||||
users, closeStorage, storage, err := openUsers(ctx, logger)
|
users, closeStorage, storage, err := openUsers(ctx, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("storage initialization failed", "error", err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
if err := closeStorage(); err != nil {
|
|
||||||
logger.Error("storage close failed", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
devToken := strings.TrimSpace(os.Getenv("GO_OMNI_DEV_TOKEN"))
|
devToken := strings.TrimSpace(os.Getenv("GO_OMNI_DEV_TOKEN"))
|
||||||
if devToken == "" {
|
if devToken == "" {
|
||||||
devToken = "dev-token"
|
devToken = "dev-token"
|
||||||
@@ -72,16 +95,11 @@ func main() {
|
|||||||
authenticator := auth.StaticAuthenticator{
|
authenticator := auth.StaticAuthenticator{
|
||||||
devToken: {UserID: "dev-user", Roles: []string{"admin"}},
|
devToken: {UserID: "dev-user", Roles: []string{"admin"}},
|
||||||
}
|
}
|
||||||
application := newProtectedApp(users, logger, authenticator)
|
application := newProtectedApp(users, logger, authenticator, omni.WithConfig(settings))
|
||||||
address := os.Getenv("GO_OMNI_ADDR")
|
application.OnShutdown(logger.Close)
|
||||||
if address == "" {
|
application.OnShutdown(closeStorage)
|
||||||
address = "127.0.0.1:8080"
|
logger.Info("starting reference API", "address", settings.Address, "transport", settings.Transport, "tls", settings.TLS.CertFile != "", "storage", storage)
|
||||||
}
|
return application.RunContext(ctx)
|
||||||
logger.Info("starting reference API", "address", address, "storage", storage)
|
|
||||||
if err := application.RunContext(ctx, address); err != nil {
|
|
||||||
logger.Error("server stopped", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func openUsers(ctx context.Context, logger *logx.Logger) (service.UserRepository, func() error, string, error) {
|
func openUsers(ctx context.Context, logger *logx.Logger) (service.UserRepository, func() error, string, error) {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/auth"
|
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
|
"git.yuntuo.hk/k2safe/go-omni/examples/reference/model"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
|
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -29,7 +31,7 @@ type envelope struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testApp() *omni.App {
|
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 {
|
func testLogger(writer io.Writer) *logx.Logger {
|
||||||
@@ -84,7 +86,6 @@ func TestProtectedWriteRoutes(t *testing.T) {
|
|||||||
repository.NewMemoryUsers(),
|
repository.NewMemoryUsers(),
|
||||||
testLogger(io.Discard),
|
testLogger(io.Discard),
|
||||||
auth.StaticAuthenticator{"user-token": {UserID: "user-1", Roles: []string{"user"}}},
|
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")
|
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"))
|
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) {
|
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 http.Handler) {
|
||||||
|
t.Helper()
|
||||||
health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK"))
|
health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK"))
|
||||||
if health["status"] != "ok" {
|
if health["status"] != "ok" {
|
||||||
t.Fatalf("unexpected health: %v", health)
|
t.Fatalf("unexpected health: %v", health)
|
||||||
@@ -207,7 +223,7 @@ func TestErrorTranslationAndRequestLogging(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
var logs bytes.Buffer
|
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)
|
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 {
|
if strings.Contains(result.Message, test.err.Error()) || len(result.Data) != 0 {
|
||||||
t.Fatalf("internal error details leaked: %+v", result)
|
t.Fatalf("internal error details leaked: %+v", result)
|
||||||
|
|||||||
182
examples/reference/transport_test.go
Normal file
182
examples/reference/transport_test.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// networkHandler runs the same business assertions through a real TCP client.
|
||||||
|
// The application handler is only called by its configured server transport.
|
||||||
|
type networkHandler struct {
|
||||||
|
t *testing.T
|
||||||
|
client *http.Client
|
||||||
|
address, scheme, token, alpn string
|
||||||
|
proto int
|
||||||
|
requests *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h networkHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.t.Helper()
|
||||||
|
req := r.Clone(r.Context())
|
||||||
|
req.RequestURI = ""
|
||||||
|
req.URL.Scheme, req.URL.Host, req.Host = h.scheme, h.address, h.address
|
||||||
|
if h.token != "" && req.Header.Get("Authorization") == "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+h.token)
|
||||||
|
}
|
||||||
|
response, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if strings.HasPrefix(req.URL.Path, "/api/v1/") {
|
||||||
|
*h.requests++
|
||||||
|
}
|
||||||
|
if response.ProtoMajor != h.proto {
|
||||||
|
h.t.Fatalf("protocol %s, want HTTP/%d", response.Proto, h.proto)
|
||||||
|
}
|
||||||
|
if h.scheme == "https" && (response.TLS == nil || len(response.TLS.VerifiedChains) == 0 || response.TLS.NegotiatedProtocol != h.alpn) {
|
||||||
|
h.t.Fatalf("TLS/ALPN verification failed: %+v", response.TLS)
|
||||||
|
}
|
||||||
|
for key, values := range response.Header {
|
||||||
|
w.Header()[key] = append([]string(nil), values...)
|
||||||
|
}
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
if _, err := io.Copy(w, response.Body); err != nil {
|
||||||
|
h.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type lockedLog struct {
|
||||||
|
sync.Mutex
|
||||||
|
bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *lockedLog) Write(p []byte) (int, error) {
|
||||||
|
b.Lock()
|
||||||
|
defer b.Unlock()
|
||||||
|
return b.Buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *lockedLog) snapshot() string {
|
||||||
|
b.Lock()
|
||||||
|
defer b.Unlock()
|
||||||
|
return b.Buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusinessConfiguredTransports(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, transport string
|
||||||
|
tls bool
|
||||||
|
proto int
|
||||||
|
alpn string
|
||||||
|
}{
|
||||||
|
{"nethttp", omni.TransportNetHTTP, false, 1, ""},
|
||||||
|
{"native", omni.TransportNative, false, 1, ""},
|
||||||
|
{"nethttpTLS", omni.TransportNetHTTP, true, 2, "h2"},
|
||||||
|
{"nativeTLS", omni.TransportNative, true, 1, "http/1.1"},
|
||||||
|
{"http2TLS", omni.TransportHTTP2, true, 2, "h2"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
|
t.Setenv("REFERENCE_TEST_TRANSPORT", test.transport)
|
||||||
|
t.Setenv("REFERENCE_TEST_SHUTDOWN_TIMEOUT", "3s")
|
||||||
|
t.Setenv("REFERENCE_TEST_TLS_CERT_FILE", "")
|
||||||
|
t.Setenv("REFERENCE_TEST_TLS_KEY_FILE", "")
|
||||||
|
if test.tls {
|
||||||
|
t.Setenv("REFERENCE_TEST_TLS_CERT_FILE", cert)
|
||||||
|
t.Setenv("REFERENCE_TEST_TLS_KEY_FILE", key)
|
||||||
|
}
|
||||||
|
settings, err := omni.LoadConfig("REFERENCE_TEST_")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var logs lockedLog
|
||||||
|
logger := testLogger(&logs)
|
||||||
|
application := newProtectedApp(repository.NewMemoryUsers(), logger, auth.StaticAuthenticator{
|
||||||
|
"admin": {UserID: "admin", Roles: []string{"admin"}},
|
||||||
|
"user": {UserID: "user", Roles: []string{"user"}},
|
||||||
|
}, omni.WithConfig(settings))
|
||||||
|
application.OnShutdown(logger.Close)
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- application.ServeContext(ctx, listener) }()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Error("business server did not stop")
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
scheme := "http"
|
||||||
|
if test.tls {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
requests := 0
|
||||||
|
network := networkHandler{t: t, client: client, address: listener.Addr().String(), scheme: scheme, proto: test.proto, alpn: test.alpn, requests: &requests}
|
||||||
|
request(t, network, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, 401, "UNAUTHENTICATED")
|
||||||
|
requestWithAuthorization(t, network, "DELETE", "/api/v1/users/1", "", "Bearer user", 403, "FORBIDDEN")
|
||||||
|
network.token = "admin"
|
||||||
|
checkUserCRUD(t, network)
|
||||||
|
request(t, network, "POST", "/api/v1/users", `{"name":`, 400, "INVALID_BODY")
|
||||||
|
count := 0
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(logs.snapshot()), "\n") {
|
||||||
|
var record map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record["requestId"] == "reference-test" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count < requests {
|
||||||
|
t.Fatalf("missing correlated business logs: %d", count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferenceAddressCompatibility(t *testing.T) {
|
||||||
|
t.Setenv("GO_OMNI_TRANSPORT", "native")
|
||||||
|
t.Setenv("GO_OMNI_TLS_CERT_FILE", "")
|
||||||
|
t.Setenv("GO_OMNI_TLS_KEY_FILE", "")
|
||||||
|
t.Setenv("GO_OMNI_HTTP_ADDR", "")
|
||||||
|
t.Setenv("GO_OMNI_ADDR", "")
|
||||||
|
settings, err := referenceConfig()
|
||||||
|
if err != nil || settings.Address != "127.0.0.1:8080" || settings.Transport != "native" {
|
||||||
|
t.Fatalf("default: %+v %v", settings, err)
|
||||||
|
}
|
||||||
|
t.Setenv("GO_OMNI_ADDR", "127.0.0.1:8081")
|
||||||
|
settings, err = referenceConfig()
|
||||||
|
if err != nil || settings.Address != "127.0.0.1:8081" {
|
||||||
|
t.Fatalf("legacy: %+v %v", settings, err)
|
||||||
|
}
|
||||||
|
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:8082")
|
||||||
|
settings, err = referenceConfig()
|
||||||
|
if err != nil || settings.Address != "127.0.0.1:8082" {
|
||||||
|
t.Fatalf("standard: %+v %v", settings, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
go.mod
21
go.mod
@@ -3,36 +3,19 @@ module git.yuntuo.hk/k2safe/go-omni
|
|||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
|
||||||
github.com/go-playground/validator/v10 v10.20.0
|
github.com/go-playground/validator/v10 v10.20.0
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
golang.org/x/net v0.25.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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/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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/stretchr/testify v1.9.0 // 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
|
|
||||||
golang.org/x/crypto v0.23.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/sys v0.20.0 // indirect
|
||||||
golang.org/x/text v0.15.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 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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=
|
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-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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
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 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
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 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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
|
type Middleware func(Handler) Handler
|
||||||
|
|
||||||
// Router is the small contract the application needs from a network layer.
|
// Router is the small contract the application needs from a network layer.
|
||||||
// The default implementation is backed by Gin, but another router or a
|
// The default implementation is backed by the standard library, but another
|
||||||
// future GoOmni transport can implement this interface without changing
|
// router or a future GoOmni transport can implement this interface without
|
||||||
// business handlers.
|
// 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 {
|
type Router interface {
|
||||||
http.Handler
|
http.Handler
|
||||||
Handle(method, path string, handler 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
|
// 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 {
|
type Context struct {
|
||||||
request *http.Request
|
request *http.Request
|
||||||
writer http.ResponseWriter
|
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.
|
// JSON is an escape hatch for responses that do not use the standard envelope.
|
||||||
func (c *Context) JSON(status int, value any) error {
|
func (c *Context) JSON(status int, value any) error {
|
||||||
|
if c.responded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if c.writer == nil {
|
if c.writer == nil {
|
||||||
return apierrors.Internal(nil)
|
return apierrors.Internal(nil)
|
||||||
}
|
}
|
||||||
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
// Encode before committing headers so an invalid value can still produce
|
||||||
c.writer.WriteHeader(status)
|
// the standard 500 response on every adapter.
|
||||||
if err := json.NewEncoder(c.writer).Encode(value); err != nil {
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
return apierrors.Internal(err)
|
return apierrors.Internal(err)
|
||||||
}
|
}
|
||||||
c.responded = true
|
c.responded = true
|
||||||
c.status = status
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,9 +198,15 @@ func (c *Context) Accepted(data any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Context) NoContent() 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.responded = true
|
||||||
c.status = http.StatusNoContent
|
c.status = http.StatusNoContent
|
||||||
|
c.writer.WriteHeader(http.StatusNoContent)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +231,8 @@ func (c *Context) Status() int {
|
|||||||
func (c *Context) Responded() bool { return c.responded }
|
func (c *Context) Responded() bool { return c.responded }
|
||||||
|
|
||||||
// WriteError writes a stable public error response. Internal causes are not
|
// 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) {
|
func (c *Context) WriteError(err error) {
|
||||||
if c.responded {
|
if c.responded {
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
internal/testutil/tls.go
Normal file
61
internal/testutil/tls.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Package testutil contains fixtures shared by transport integration tests.
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CertificateFiles creates a temporary localhost certificate and a client
|
||||||
|
// configuration that verifies it. No insecure TLS bypass is needed in tests.
|
||||||
|
func CertificateFiles(t testing.TB) (certFile, keyFile string, client *tls.Config) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
certificate := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||||
|
DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
certFile, keyFile = filepath.Join(directory, "server.crt"), filepath.Join(directory, "server.key")
|
||||||
|
if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
leaf, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
roots := x509.NewCertPool()
|
||||||
|
roots.AddCert(leaf)
|
||||||
|
return certFile, keyFile, &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# logx
|
# logx
|
||||||
|
|
||||||
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖 Gin:
|
`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖具体网络库:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "git.yuntuo.hk/k2safe/go-omni/logx"
|
import "git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package logx provides the application logger used by GoOmni and business
|
// 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.
|
// logger can be used from HTTP handlers, jobs and command line programs.
|
||||||
package logx
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
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
|
// Recovery converts panics from handlers and middleware into the standard
|
||||||
// internal error. The network adapter does not need a Gin-specific recovery
|
// internal error. Recovery belongs to GoOmni's transport-neutral middleware
|
||||||
// hook for this behavior.
|
// rather than a router-specific hook.
|
||||||
func Recovery() httpx.Middleware {
|
func Recovery() httpx.Middleware {
|
||||||
return func(next httpx.Handler) httpx.Handler {
|
return func(next httpx.Handler) httpx.Handler {
|
||||||
return func(ctx *httpx.Context) (err error) {
|
return func(ctx *httpx.Context) (err error) {
|
||||||
@@ -55,7 +54,7 @@ func AccessLog() httpx.Middleware {
|
|||||||
started := time.Now()
|
started := time.Now()
|
||||||
err := next(ctx)
|
err := next(ctx)
|
||||||
status := ctx.Status()
|
status := ctx.Status()
|
||||||
if err != nil {
|
if err != nil && !ctx.Responded() {
|
||||||
status = apierrors.From(err).Status
|
status = apierrors.From(err).Status
|
||||||
}
|
}
|
||||||
logx.InfoContext(ctx.Context(), "http.request",
|
logx.InfoContext(ctx.Context(), "http.request",
|
||||||
@@ -64,19 +63,9 @@ func AccessLog() httpx.Middleware {
|
|||||||
"route", httpx.RoutePattern(ctx.Context()),
|
"route", httpx.RoutePattern(ctx.Context()),
|
||||||
"status", status,
|
"status", status,
|
||||||
"durationMs", time.Since(started).Seconds()*1000,
|
"durationMs", time.Since(started).Seconds()*1000,
|
||||||
"clientIp", clientIP(ctx.Request()),
|
"clientIp", ctx.ClientIP(),
|
||||||
)
|
)
|
||||||
return err
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
omni.go
20
omni.go
@@ -9,10 +9,12 @@ import (
|
|||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/metrics"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App = app.App
|
type App = app.App
|
||||||
|
type Runner = app.Runner
|
||||||
type RouterGroup = app.RouterGroup
|
type RouterGroup = app.RouterGroup
|
||||||
type Principal = auth.Principal
|
type Principal = auth.Principal
|
||||||
type Authenticator = auth.Authenticator
|
type Authenticator = auth.Authenticator
|
||||||
@@ -23,10 +25,18 @@ type Router = httpx.Router
|
|||||||
type HTTPRouterGroup = httpx.RouterGroup
|
type HTTPRouterGroup = httpx.RouterGroup
|
||||||
type APIError = apierrors.APIError
|
type APIError = apierrors.APIError
|
||||||
type Config = config.Config
|
type Config = config.Config
|
||||||
|
type TLSConfig = config.TLSConfig
|
||||||
type Option = config.Option
|
type Option = config.Option
|
||||||
type Response = response.Response
|
type Response = response.Response
|
||||||
type LogConfig = logx.Config
|
type LogConfig = logx.Config
|
||||||
type Logger = logx.Logger
|
type Logger = logx.Logger
|
||||||
|
type Metrics = metrics.Metrics
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransportNetHTTP = config.TransportNetHTTP
|
||||||
|
TransportNative = config.TransportNative
|
||||||
|
TransportHTTP2 = config.TransportHTTP2
|
||||||
|
)
|
||||||
|
|
||||||
func New(options ...Option) *App { return app.New(options...) }
|
func New(options ...Option) *App { return app.New(options...) }
|
||||||
|
|
||||||
@@ -34,14 +44,18 @@ func NewWithRouter(router Router, options ...Option) *App {
|
|||||||
return app.NewWithRouter(router, options...)
|
return app.NewWithRouter(router, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithMode(mode string) Option { return config.WithMode(mode) }
|
|
||||||
|
|
||||||
func WithRequestIDHeader(header string) Option { return config.WithRequestIDHeader(header) }
|
func WithRequestIDHeader(header string) Option { return config.WithRequestIDHeader(header) }
|
||||||
|
|
||||||
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
|
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 WithConfig(settings Config) Option { return config.WithConfig(settings) }
|
||||||
|
|
||||||
|
func WithTransport(name string) Option { return config.WithTransport(name) }
|
||||||
|
|
||||||
|
func WithTLS(certFile, keyFile string) Option { return config.WithTLS(certFile, keyFile) }
|
||||||
|
|
||||||
func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) }
|
func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) }
|
||||||
|
|
||||||
func WithShutdownTimeout(timeout time.Duration) Option { return config.WithShutdownTimeout(timeout) }
|
func WithShutdownTimeout(timeout time.Duration) Option { return config.WithShutdownTimeout(timeout) }
|
||||||
@@ -50,6 +64,8 @@ func LoadConfig(prefix string) (Config, error) { return config.LoadFromEnv(prefi
|
|||||||
|
|
||||||
func SetupLogger(cfg LogConfig) (func() error, error) { return logx.Setup(cfg) }
|
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 {
|
func NewError(status int, code, message string) *APIError {
|
||||||
return apierrors.New(status, code, message)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
55
performance/transportStartup.md
Normal file
55
performance/transportStartup.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 统一启动配置与业务接入验收
|
||||||
|
|
||||||
|
2026-09-23,代码提交 `be620d2243ff85288f0afc44689c2e46abd714ec`。
|
||||||
|
|
||||||
|
`omni.New(omni.WithConfig(settings))` 现在通过 `Run`、`RunContext` 或 `ServeContext`
|
||||||
|
统一选择 nethttp、native 或实验 HTTP/2,加载 TLS 证书,并应用退出等待时间。
|
||||||
|
默认仍是 nethttp;HTTP/2 必须显式选择并配置 TLS,native 仍只支持 HTTP/1.1。
|
||||||
|
`RunWith` 继续支持自定义 Runner,由 Runner 管理自己的 TLS、超时及其他网络参数。
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
|
||||||
|
| 场景 | 检查 |
|
||||||
|
| --- | --- |
|
||||||
|
| nethttp / native 明文;nethttp / native / HTTP2 TLS | 参考业务通过真实 TCP 执行 CRUD、分页、认证权限、参数错误、冲突和 404;校验响应及日志 requestId |
|
||||||
|
| 三种 TLS 启动 | 使用临时受信任证书,验证证书链及 ALPN;nethttp、HTTP2 为 h2,native 为 http/1.1 |
|
||||||
|
| 配置错误 | 未知适配器、HTTP2 缺 TLS、证书不配对、证书无法读取,在监听前失败且只清理一次 |
|
||||||
|
| 已有 listener / 自定义 Runner | 配置失败或取消时关闭 listener;自定义 Runner 入口继续独立于内置适配器选择 |
|
||||||
|
| 正常关闭 | 停止监听后,在途请求仍能成功完成,然后清理资源 |
|
||||||
|
| 关闭超时 | 取消请求、关闭连接并返回超时;不响应取消的 Handler 不会无限阻塞框架返回 |
|
||||||
|
| native 响应完成与关闭并发 | 在响应已经选择 Keep-Alive 后暂停写入,再触发关闭;响应写完后连接立即回收,退出无超时 |
|
||||||
|
|
||||||
|
框架无法强制终止不响应取消的业务 goroutine。业务仍需处理 context 取消;每个 native Server
|
||||||
|
只用于一次服务生命周期,重启时创建新实例,以免超时后的旧 Handler 与新实例混用。
|
||||||
|
|
||||||
|
## Linux 结果
|
||||||
|
|
||||||
|
Go 1.25.14、Linux amd64、`golang:1.25` 镜像的实际 image ID、完整命令与代码提交
|
||||||
|
记录于 [通过报告](transportStartupLinux.json),[原始输出](transportStartupLinux.txt) 的 SHA-256 已核对。
|
||||||
|
|
||||||
|
- 全仓库 `go test -race -p 1 -count=1 -timeout=3m ./...`、`go vet -p 1 ./...`、`go build -p 1 ./...` 通过。
|
||||||
|
- 上表的配置、TLS 业务、退出和 native 并发回收测试重复 10 次,全部通过。
|
||||||
|
- 单个验证容器限制为 2 核、2 GiB 内存、同额 memory-swap(无额外 swap)、256 个进程/线程,GOMAXPROCS=2。
|
||||||
|
- Docker inspect 已确认限制生效,代码只读挂载,没有发布端口;测试流量仅访问容器内 loopback。
|
||||||
|
- 总时限 480 秒;完成后验证容器已删除,之前运行的 7 个业务容器仍在运行。
|
||||||
|
|
||||||
|
首轮重复测试曾在 native 排空阶段失败,记录保留在
|
||||||
|
[修复前报告](transportStartupBeforeFixLinux.json) 和 [修复前输出](transportStartupBeforeFixLinux.txt)。
|
||||||
|
原因是响应写入和关闭并发时,连接转为空闲后可能再次等待 Keep-Alive 请求,直到关闭超时。
|
||||||
|
新增暂停响应写入的确定性测试在修复前失败;修复将“转为空闲”和“判断是否关闭”置于同一临界区,
|
||||||
|
最终报告包含修复后的重复回归。首轮失败不计作通过证据。
|
||||||
|
|
||||||
|
## 本地与脚手架
|
||||||
|
|
||||||
|
macOS arm64、Go 1.27.1 的全仓库 race、vet、build 通过;app/native 包额外重复 10 次 race 检查通过。
|
||||||
|
实际生成的独立业务模块通过以下入口验证 native 启动、HTTP 请求、SIGINT / SIGTERM、退出码 0、
|
||||||
|
端口关闭,以及 `omni.New().Run` 的资源清理回调:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build -o /tmp/go-omni-startup-cli ./cmd/go-omni
|
||||||
|
python3 scripts/verifyScaffold.py --cli /tmp/go-omni-startup-cli --framework-dir . --transport native
|
||||||
|
```
|
||||||
|
|
||||||
|
独立模块通过本地 replace 引用被测源码;这不表示旧的 `v0.6.0-preview` 发布标签已包含新增配置。
|
||||||
|
本轮是功能、并发和生命周期验收,没有重新进行 30 分钟长测,也不代表 native 已合并 HTTP/2
|
||||||
|
或已经在真实业务环境发布。下一步是业务试用及 native 多协议监听器的独立设计与验收。
|
||||||
69
performance/transportStartupBeforeFixLinux.json
Normal file
69
performance/transportStartupBeforeFixLinux.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"startedAt": "2026-09-23T04:51:00.973573+00:00",
|
||||||
|
"commit": "8e18528fc2355354e63e16fbb1742bd5fdce1789",
|
||||||
|
"imageId": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||||
|
"command": [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--name",
|
||||||
|
"go-omni-startup-ea6d51f9c46e",
|
||||||
|
"--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 -p 1 -count=1 -timeout=3m ./... && go vet -p 1 ./... && go build -p 1 ./... && go test -race -p 1 -count=3 -timeout=90s -v ./app ./examples/reference -run 'Test(Configured|RunContextConfigured|RunWithRemains|BusinessConfigured)'"
|
||||||
|
],
|
||||||
|
"limits": {
|
||||||
|
"cpus": 2,
|
||||||
|
"memoryBytes": 2147483648,
|
||||||
|
"memorySwapBytes": 2147483648,
|
||||||
|
"pids": 256
|
||||||
|
},
|
||||||
|
"wallTimeoutSeconds": 480,
|
||||||
|
"businessContainersBefore": [
|
||||||
|
"etax-mysql-test-mysql-1",
|
||||||
|
"etax-api-test-api-1",
|
||||||
|
"etax-event-worker-test-event-worker-1",
|
||||||
|
"etax-event-scheduler-test-event-scheduler-1",
|
||||||
|
"etax-id-test-id-service-a-1",
|
||||||
|
"etax-id-test-id-service-b-1",
|
||||||
|
"etax-clamav-test-clamav-1"
|
||||||
|
],
|
||||||
|
"publishedPorts": [],
|
||||||
|
"businessContainersAfter": [
|
||||||
|
"etax-mysql-test-mysql-1",
|
||||||
|
"etax-api-test-api-1",
|
||||||
|
"etax-event-worker-test-event-worker-1",
|
||||||
|
"etax-event-scheduler-test-event-scheduler-1",
|
||||||
|
"etax-id-test-id-service-a-1",
|
||||||
|
"etax-id-test-id-service-b-1",
|
||||||
|
"etax-clamav-test-clamav-1"
|
||||||
|
],
|
||||||
|
"businessContainersPreserved": true,
|
||||||
|
"validationContainerRemoved": true,
|
||||||
|
"finishedAt": "2026-09-23T04:53:23.164450+00:00",
|
||||||
|
"exitCode": 1,
|
||||||
|
"logSha256": "2ae3f967d3c5fed1c3534deccec988b9a7d99484cfa5da5851ebec9d6178437f"
|
||||||
|
}
|
||||||
196
performance/transportStartupBeforeFixLinux.txt
Normal file
196
performance/transportStartupBeforeFixLinux.txt
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
go version go1.25.14 linux/amd64
|
||||||
|
go: downloading github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
go: downloading golang.org/x/net v0.25.0
|
||||||
|
go: downloading github.com/go-playground/validator/v10 v10.20.0
|
||||||
|
go: downloading filippo.io/edwards25519 v1.1.0
|
||||||
|
go: downloading github.com/gabriel-vasile/mimetype v1.4.3
|
||||||
|
go: downloading github.com/go-playground/universal-translator v0.18.1
|
||||||
|
go: downloading github.com/leodido/go-urn v1.4.0
|
||||||
|
go: downloading golang.org/x/crypto v0.23.0
|
||||||
|
go: downloading golang.org/x/text v0.15.0
|
||||||
|
go: downloading github.com/go-playground/locales v0.14.1
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni 1.086s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app 1.395s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/auth 1.037s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.127s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 1.133s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/config 1.035s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database 1.013s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.151s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.013s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/health 1.039s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.033s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.047s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/logx 1.013s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.036s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.036s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/response [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport 1.053s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.452s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.053s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.034s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
transport_test.go:98: context deadline exceeded
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- FAIL: TestConfiguredTransportLifecycle (3.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- FAIL: TestConfiguredTransportLifecycle/native/drain (3.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
FAIL
|
||||||
|
FAIL git.yuntuo.hk/k2safe/go-omni/app 4.025s
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.297s
|
||||||
|
FAIL
|
||||||
81
performance/transportStartupLinux.json
Normal file
81
performance/transportStartupLinux.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"startedAt": "2026-09-23T04:57:45.377028+00:00",
|
||||||
|
"commit": "be620d2243ff85288f0afc44689c2e46abd714ec",
|
||||||
|
"imageId": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||||
|
"command": [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--name",
|
||||||
|
"go-omni-startup-4da776773736",
|
||||||
|
"--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 -p 1 -count=1 -timeout=3m ./... && go vet -p 1 ./... && go build -p 1 ./... && go test -race -p 1 -count=10 -timeout=120s -v ./app ./examples/reference ./transport/native -run 'Test(Configured|RunContextConfigured|RunWithRemains|BusinessConfigured|ShutdownDuringResponse)'"
|
||||||
|
],
|
||||||
|
"limits": {
|
||||||
|
"cpus": 2,
|
||||||
|
"memoryBytes": 2147483648,
|
||||||
|
"memorySwapBytes": 2147483648,
|
||||||
|
"pids": 256
|
||||||
|
},
|
||||||
|
"wallTimeoutSeconds": 480,
|
||||||
|
"businessContainersBefore": [
|
||||||
|
"etax-mysql-test-mysql-1",
|
||||||
|
"etax-api-test-api-1",
|
||||||
|
"etax-event-worker-test-event-worker-1",
|
||||||
|
"etax-event-scheduler-test-event-scheduler-1",
|
||||||
|
"etax-id-test-id-service-a-1",
|
||||||
|
"etax-id-test-id-service-b-1",
|
||||||
|
"etax-clamav-test-clamav-1"
|
||||||
|
],
|
||||||
|
"publishedPorts": [],
|
||||||
|
"businessContainersAfter": [
|
||||||
|
"etax-mysql-test-mysql-1",
|
||||||
|
"etax-api-test-api-1",
|
||||||
|
"etax-event-worker-test-event-worker-1",
|
||||||
|
"etax-event-scheduler-test-event-scheduler-1",
|
||||||
|
"etax-id-test-id-service-a-1",
|
||||||
|
"etax-id-test-id-service-b-1",
|
||||||
|
"etax-clamav-test-clamav-1"
|
||||||
|
],
|
||||||
|
"businessContainersPreserved": true,
|
||||||
|
"validationContainerRemoved": true,
|
||||||
|
"finishedAt": "2026-09-23T05:00:13.275484+00:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"logSha256": "41ea889d2df841c5670e0526c1931a727c741fd42a2c51e47ac34302b1ca5434",
|
||||||
|
"observedLimits": {
|
||||||
|
"nanoCpus": 2000000000,
|
||||||
|
"memoryBytes": 2147483648,
|
||||||
|
"memorySwapBytes": 2147483648,
|
||||||
|
"pids": 256,
|
||||||
|
"portBindings": {},
|
||||||
|
"binds": [
|
||||||
|
"/root/go-omni-validation:/workspace:ro"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scope": "Full repository race tests, vet and build; ten repeats of configured startup, TLS business, graceful drain, forced shutdown and native idle-transition regression.",
|
||||||
|
"previousFailure": "transportStartupBeforeFixLinux.json"
|
||||||
|
}
|
||||||
646
performance/transportStartupLinux.txt
Normal file
646
performance/transportStartupLinux.txt
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
go version go1.25.14 linux/amd64
|
||||||
|
go: downloading github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
go: downloading golang.org/x/net v0.25.0
|
||||||
|
go: downloading github.com/go-playground/validator/v10 v10.20.0
|
||||||
|
go: downloading filippo.io/edwards25519 v1.1.0
|
||||||
|
go: downloading github.com/gabriel-vasile/mimetype v1.4.3
|
||||||
|
go: downloading github.com/go-playground/universal-translator v0.18.1
|
||||||
|
go: downloading github.com/leodido/go-urn v1.4.0
|
||||||
|
go: downloading golang.org/x/crypto v0.23.0
|
||||||
|
go: downloading golang.org/x/text v0.15.0
|
||||||
|
go: downloading github.com/go-playground/locales v0.14.1
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni 1.086s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app 1.653s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/auth 1.036s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.124s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 1.133s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/config 1.036s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database 1.013s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.156s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.013s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/health 1.041s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.034s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.048s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/logx 1.013s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.034s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.036s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/response [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport 1.051s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.551s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.049s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.035s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.04s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.01s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.27s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
=== RUN TestConfiguredTransportLifecycle
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||||
|
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||||
|
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||||
|
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||||
|
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||||
|
=== RUN TestRunContextConfiguredTLS
|
||||||
|
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||||
|
=== RUN TestRunContextConfiguredTLS/native
|
||||||
|
=== RUN TestRunContextConfiguredTLS/http2
|
||||||
|
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||||
|
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||||
|
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||||
|
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||||
|
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||||
|
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app 6.879s
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
=== RUN TestBusinessConfiguredTransports
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||||
|
=== RUN TestBusinessConfiguredTransports/native
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||||
|
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||||
|
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||||
|
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.885s
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||||
|
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/native 1.058s
|
||||||
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()
|
||||||
163
scripts/verifyScaffold.py
Normal file
163
scripts/verifyScaffold.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify a generated service outside the source tree using a real server process."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
MODULE = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, cwd, env):
|
||||||
|
result = subprocess.run(args, cwd=cwd, env=env, text=True,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=180)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f"{args[0]} {args[1:]} failed:\n{result.stdout}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None, transport="nethttp"):
|
||||||
|
with socket.socket() as listener:
|
||||||
|
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_TRANSPORT=transport,
|
||||||
|
GO_OMNI_ENABLE_LOGGER="true", GO_OMNI_SHUTDOWN_TIMEOUT="2s")
|
||||||
|
if cleanupMarker:
|
||||||
|
serverEnv["GO_OMNI_CLEANUP_MARKER"] = str(cleanupMarker)
|
||||||
|
logFile = directory / f"{binary.name}-{stopSignal.name}.log"
|
||||||
|
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||||
|
|
||||||
|
def request(path, data=None):
|
||||||
|
req = urllib.request.Request(f"http://{address}{path}", data=data,
|
||||||
|
headers={"Content-Type": "application/json", "X-Request-ID": "smokeRequest"})
|
||||||
|
with client.open(req, timeout=2) as response:
|
||||||
|
if response.headers.get("X-Request-ID") != "smokeRequest":
|
||||||
|
raise AssertionError("request ID was not preserved")
|
||||||
|
return response.status, json.load(response)
|
||||||
|
|
||||||
|
with logFile.open("w") as logs:
|
||||||
|
process = subprocess.Popen([str(binary)], cwd=directory, env=serverEnv,
|
||||||
|
stdout=logs, stderr=subprocess.STDOUT)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while True:
|
||||||
|
if process.poll() is not None:
|
||||||
|
raise RuntimeError(f"server exited during startup: {logFile.read_text()}")
|
||||||
|
try:
|
||||||
|
status, body = request("/health/live")
|
||||||
|
break
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise RuntimeError(f"server did not start: {logFile.read_text()}")
|
||||||
|
time.sleep(0.05)
|
||||||
|
if status != 200 or body["data"]["status"] != "ok":
|
||||||
|
raise AssertionError(f"unexpected liveness: {body}")
|
||||||
|
if not cleanupMarker:
|
||||||
|
status, body = request("/health/ready")
|
||||||
|
if status != 200 or body["code"] != "READY":
|
||||||
|
raise AssertionError(f"unexpected readiness: {body}")
|
||||||
|
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)
|
||||||
|
exitCode = process.wait(timeout=8)
|
||||||
|
if exitCode != 0:
|
||||||
|
raise AssertionError(f"{stopSignal.name} exit code {exitCode}: {logFile.read_text()}")
|
||||||
|
if cleanupMarker and cleanupMarker.read_text() != "closed":
|
||||||
|
raise AssertionError("shutdown callback was not executed")
|
||||||
|
# A built binary, rather than `go run`, lets us verify the actual
|
||||||
|
# service exited without leaving its child listening in the background.
|
||||||
|
with socket.socket() as connection:
|
||||||
|
connection.settimeout(1)
|
||||||
|
if connection.connect_ex(("127.0.0.1", int(address.rsplit(":", 1)[1]))) == 0:
|
||||||
|
raise AssertionError("server still accepts connections after exit")
|
||||||
|
finally:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
print(f"PASS {binary.name} ({transport}): HTTP checks, {stopSignal.name}, exit 0, listener closed", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--go", default="go", help="Go executable")
|
||||||
|
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")
|
||||||
|
parser.add_argument("--transport", choices=("nethttp", "native"), default="nethttp", help="Plain HTTP transport to exercise (native requires a framework supporting unified transport configuration)")
|
||||||
|
parser.add_argument("--skip-run-check", action="store_true", help="Skip core Run signal checks when testing an older framework")
|
||||||
|
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 smoke ") as temporary:
|
||||||
|
directory = Path(temporary).resolve()
|
||||||
|
if args.cli:
|
||||||
|
cli = Path(args.cli).resolve()
|
||||||
|
else:
|
||||||
|
env["GOBIN"] = str(directory / "bin")
|
||||||
|
print(f"Installing remote CLI {args.version}", flush=True)
|
||||||
|
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/scaffoldSmoke"]
|
||||||
|
if args.framework_version:
|
||||||
|
command += ["--framework-version", args.framework_version]
|
||||||
|
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))
|
||||||
|
requirements = {entry["Path"]: entry["Version"] for entry in module["Require"]}
|
||||||
|
expectedVersion = args.framework_version or args.version
|
||||||
|
if requirements.get(MODULE) != expectedVersion:
|
||||||
|
raise AssertionError(f"expected framework {expectedVersion}, got {requirements}")
|
||||||
|
if not args.framework_dir and module.get("Replace"):
|
||||||
|
raise AssertionError("remote generation must not contain local replace directives")
|
||||||
|
print(f"PASS generated module pins {expectedVersion}; local replace: {bool(module.get('Replace'))}", flush=True)
|
||||||
|
run([args.go, "mod", "tidy"], project, env)
|
||||||
|
run([args.go, "test", "./..."], project, env)
|
||||||
|
binary = directory / "server"
|
||||||
|
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
|
||||||
|
for stopSignal in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
exerciseServer(binary, project, env, stopSignal, transport=args.transport)
|
||||||
|
|
||||||
|
if not args.skip_run_check:
|
||||||
|
# Verify the framework's convenient Run API independently from the
|
||||||
|
# generated main's caller-owned signal context.
|
||||||
|
helper = project / "cmd" / "runCheck"
|
||||||
|
helper.mkdir()
|
||||||
|
helper.joinpath("main.go").write_text('''package main
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
)
|
||||||
|
func main() {
|
||||||
|
settings, err := omni.LoadConfig("")
|
||||||
|
if err != nil { log.Fatal(err) }
|
||||||
|
app := omni.New(omni.WithConfig(settings))
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
runBinary = directory / "runCheck"
|
||||||
|
run([args.go, "build", "-o", str(runBinary), "./cmd/runCheck"], project, env)
|
||||||
|
for stopSignal in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
marker = project / f"cleanup-{stopSignal.name}"
|
||||||
|
exerciseServer(runBinary, project, env, stopSignal, marker, transport=args.transport)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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
|
# Transport adapters
|
||||||
|
|
||||||
GoOmni 的业务处理链依赖 `httpx.Router`,不直接依赖具体网络框架。当前的 `transport/gin` 只负责把 Gin 的路由、路径参数和响应写入转换成标准 HTTP 接口;`transport/nethttp` 提供了一个只使用标准库的参考实现。
|
业务 Handler 依赖 GoOmni 的 `httpx.Context`,Service 接收标准 `context.Context`。适配器只在启动入口组装:
|
||||||
|
|
||||||
使用标准库适配器:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
application := omni.NewWithRouter(netrouter.New())
|
application := omni.NewWithRouter(netrouter.New())
|
||||||
|
registerRoutes(application) // 与 omni.New() 复用同一份业务注册代码
|
||||||
```
|
```
|
||||||
|
|
||||||
后续自研网络层时,实现以下能力即可接入现有 `app`、中间件和业务 Handler:
|
`transport/nethttp` 是当前内置的标准库路由实现。它提供一套可移植的路径、方法和错误回退规则;自研网络层可以直接实现同一个 `httpx.Router` 契约。
|
||||||
|
|
||||||
- `httpx.Router`:注册路由、分组并实现 `http.Handler`。
|
`transport/http2` 是独立的 opt-in 实验 Runner,负责连接接入、TLS/ALPN、连接上限和全局请求背压,底层帧解析与 stream 流控暂由 `golang.org/x/net/http2` 提供。它不替换 `transport/native`,也不改变默认启动器;只有通过 `App.RunWith(ctx, http2.New(nil), address)` 显式启用才会运行。
|
||||||
- `httpx.RouterGroup`:注册分组内路由。
|
|
||||||
- 适配器为请求 context 注入 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern`。
|
|
||||||
|
|
||||||
业务层不应该导入具体 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user