Compare commits

...

47 Commits

Author SHA1 Message Date
467c890363 docs: record unified transport startup and shutdown acceptance 2026-09-23 13:02:46 +08:00
be620d2243 fix: bound native shutdown and close connections that become idle during drain 2026-09-23 12:57:27 +08:00
8e18528fc2 feat: configure application transports and unify graceful shutdown 2026-09-23 12:49:56 +08:00
59bf04a461 docs: record verified HTTP2 proxy and rolling drain acceptance 2026-09-23 11:18:25 +08:00
b54a303ccd test: add isolated Nginx HTTP2 upstream and rolling drain checks 2026-09-23 11:09:34 +08:00
84670f7be1 docs: record HTTP2 mixed soak and Linux flow control acceptance 2026-09-23 10:54:14 +08:00
fa2be5e016 test: verify HTTP2 connection window recovery and shutdown 2026-09-23 10:32:28 +08:00
dfe8a1a9af test: add TLS mixed soak with paced clients 2026-09-23 10:18:49 +08:00
830cb3a7ad test: complete bounded HTTP2 TLS soak and flow control checks 2026-09-23 10:06:30 +08:00
d94df4fc54 fix: bound HTTP2 soak resources and cleanup 2026-09-23 01:23:20 +08:00
1a534c7976 fix: allow proxy image startup time 2026-09-23 00:52:49 +08:00
2b93545507 chore: support configurable Go module proxy 2026-09-23 00:47:19 +08:00
15825f0ab8 docs: update HTTP2 acceptance gates 2026-09-23 00:23:02 +08:00
1b9215114f feat: observe HTTP2 reset and goaway frames 2026-09-23 00:20:37 +08:00
73a05b8c3c fix: extend HTTP2 soak test timeout 2026-09-22 23:51:11 +08:00
64a9fde204 test: verify HTTP2 proxy negotiation 2026-09-22 23:43:34 +08:00
ca0e1d6b92 docs: document HTTP2 soak command 2026-09-22 23:37:35 +08:00
98f17660c8 fix: keep HTTP2 soak context through response body 2026-09-22 23:35:23 +08:00
c8660026ce chore: add HTTP2 Linux soak runner 2026-09-22 23:26:37 +08:00
695bdb7a00 fix: count only active HTTP2 goaway sessions 2026-09-22 23:20:57 +08:00
7e9e8aa67c feat: count graceful HTTP2 goaways 2026-09-22 23:16:06 +08:00
de33eda846 test: add configurable HTTP2 TLS soak 2026-09-22 23:10:19 +08:00
dd7d3fef0a feat: expose HTTP2 header and protocol limits 2026-09-22 22:54:55 +08:00
46c5c9406c fix: apply default HTTP2 body limit 2026-09-22 22:43:07 +08:00
1487a96f67 feat: harden experimental HTTP2 request limits 2026-09-22 22:38:37 +08:00
1a20ab53ac test: wait for native connection slot release 2026-09-22 22:28:32 +08:00
a072da4a31 feat: make HTTP2 shutdown graceful 2026-09-22 22:25:35 +08:00
fcd6f48b53 feat: add opt-in HTTP2 transport experiment 2026-09-22 19:08:19 +08:00
83633f724e docs: define native HTTP2 entry criteria 2026-09-22 18:53:03 +08:00
a523e6ba3f feat: make native TLS protocol boundary explicit 2026-09-22 18:39:57 +08:00
4a8feaca36 test: wait for native request slot release 2026-09-22 18:24:51 +08:00
95dc4568f6 feat: add trusted proxy identity and overload stats 2026-09-22 18:19:39 +08:00
2259606f72 chore: label proxy timeout snapshot 2026-09-22 17:38:03 +08:00
35b7470be2 fix: keep proxy verifier control stdin 2026-09-22 17:35:51 +08:00
4f97aa0f3d test: inspect proxy drain state 2026-09-22 16:16:36 +08:00
3b4d0a3fad test: force proxy container drain 2026-09-22 16:12:22 +08:00
a4d843b8f4 test: drain proxy before native shutdown 2026-09-22 16:07:37 +08:00
fadd17a4d4 test: drain proxy timeout before shutdown 2026-09-22 16:03:37 +08:00
ac46071dfe chore: remove unused proxy verifier import 2026-09-22 15:28:25 +08:00
52d603c31e test: diagnose proxy startup failures 2026-09-22 15:27:49 +08:00
42021538b2 fix: report proxy shutdown failures 2026-09-22 15:23:46 +08:00
ca707761fc fix: stabilize proxy verification readiness 2026-09-22 15:15:32 +08:00
30fdad44c2 test: add reverse proxy deployment check 2026-09-22 15:06:17 +08:00
b7dc36fc99 test: verify request overload recovery 2026-09-22 14:57:25 +08:00
8e474a24d9 feat: expose native server stats 2026-09-22 10:04:26 +08:00
ef98d1dd8d feat: add transport-neutral request metrics 2026-09-22 10:00:02 +08:00
5ab906098f feat: stream native request bodies 2026-09-22 09:57:09 +08:00
67 changed files with 14070 additions and 227 deletions

View File

@@ -2,6 +2,23 @@
## 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 以及异常请求测试。
@@ -10,13 +27,15 @@
- native 在长 Handler 运行期间监测客户端断开并取消请求 context,流式写失败同步取消;短请求延迟启动监测,管线数据保留给下一次请求。补充断开、监测退出和长连接复用验收。
- native 增加 `ServeTLS` / `RunTLS` 和 `TLSConfig` 启动入口,App.RunWith 可以直接启用 TLS;补充真实 TLS 客户端握手、请求和优雅退出验收。
- native 请求补齐 `Request.TLS` 连接状态;Linux/arm64 容器通过全量 race、vet、build,以及 TLS 并发、Keep-Alive、慢握手和连接上限回收验收。
- 补充慢请求体超时、大请求体和同连接管线请求验收,确认请求体未完整读取时不会进入业务 Handler,连续请求不会串线。
- 补充慢请求体超时、大请求体和同连接管线请求验收,确认流式 body 读取超时会取消请求并关闭连接,连续请求不会串线。
- 补充 `MaxConnections` 满载拒绝与槽位释放验收,确认请求完成后连接容量可再次使用。
- 补充 `MaxConcurrentRequests` 过载恢复验收,确认 503 拒绝后请求槽位释放并可继续处理新请求。
- 增加高频建连/断连的状态池 churn 验收,覆盖连接对象回收与容量复用。
- 新增 Linux Nginx 反向代理验收脚本,覆盖 native TLS、Keep-Alive、上游超时和优雅关闭。
## v0.6.0-preview
- native 网络层完成慢请求头、慢 Content-Length / chunked body、慢 TLS 握手和 TLS 请求元数据验收,未完整读取的请求不会进入业务 Handler。
- 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 压测。

View File

@@ -23,6 +23,9 @@ python3 scripts/verifyLinux.py --load --tls --duration 30s \
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 或传输错误均计入失败,不能把快速返回的错误页算成成功吞吐。
@@ -75,6 +78,8 @@ Linux arm64 容器使用 Go 1.25.14、GOMAXPROCS=4 运行 3 秒矩阵,见 [tls
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 应按这个含义理解,不能作为服务端延迟或独立内存指标。

View File

@@ -38,12 +38,14 @@ go-omni/
├── health/ # 存活和就绪检查
├── middleware/ # 请求 ID 等基础中间件
├── logx/ # 业务层结构化日志
├── metrics/ # 请求指标与状态统计
├── config/ # 配置管理
├── validate/ # 参数校验
├── auth/ # 认证和权限接口
├── database/ # database/sql 生命周期和事务
├── transport/nethttp/ # 标准库网络适配器
├── transport/native/ # 自研 HTTP/1.1 服务端预览
├── transport/http2/ # opt-in HTTP/2 实验适配器
├── examples/ # 示例项目
└── cmd/ # go-omni 项目脚手架
```
@@ -123,6 +125,12 @@ if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil {
`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
@@ -132,6 +140,16 @@ func (s *UserService) Create(ctx *httpx.Context) error {
}
```
需要基础运行指标时,可以注册 transport-neutral metrics middleware 和接口:
```go
requestMetrics := omni.NewMetrics()
app.Use(requestMetrics.Middleware())
app.GET("/metrics", requestMetrics.Handler())
```
接口返回请求总数、错误数、当前处理中请求、累计耗时和 HTTP 状态分布。它是进程内快照,生产环境可由业务侧定时采集到监控系统。
`WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。
应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`:
@@ -151,9 +169,40 @@ application.OnShutdown(logger.Close)
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR`、`GO_OMNI_SHUTDOWN_TIMEOUT` 和
`GO_OMNI_TRUSTED_PROXIES`。可信代理变量使用逗号分隔的 IP/CIDR,例如
`GO_OMNI_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8`;只有来自这些地址的连接才会解析
`X-Forwarded-For`,默认只记录 socket 对端地址。数据库连接仍由
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
网络层也由同一份配置选择,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`。
@@ -207,6 +256,18 @@ python3 scripts/verifyBusiness.py --version v0.6.0-preview
标准库与 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

View File

@@ -42,13 +42,31 @@
- 标准库与 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 回收正常。
- 自动验收入口:`python3 scripts/verifyPerformance.py`;Linux 固定验收入口:`python3 scripts/verifyLinux.py --load --tls --duration 30m`。真实部署仍需按目标代理和内核参数执行 30 分钟发布前压测。
- 目标 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、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。

View File

@@ -21,16 +21,17 @@ import (
// App is the top-level GoOmni application. Its public behavior depends on
// httpx.Router, leaving the network implementation replaceable.
type App struct {
router httpx.Router
config config.Config
middlewares []httpx.Middleware
closers []func() error
closeOnce sync.Once
closeErr error
router httpx.Router
config config.Config
middlewares []httpx.Middleware
closers []func() error
closeOnce sync.Once
closeErr error
clientIPResolver *httpx.ClientIPResolver
}
// Runner is the small lifecycle boundary used by an HTTP server
// implementation. The default App.RunContext uses net/http; a custom runner
// 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
@@ -63,12 +64,17 @@ func configure(options ...config.Option) config.Config {
}
func newApp(router httpx.Router, settings config.Config) *App {
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...)
if err != nil {
panic("go-omni: " + err.Error())
}
middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)}
if settings.EnableLogger {
middlewares = append(middlewares, middleware.AccessLog())
}
middlewares = append(middlewares, middleware.Recovery())
return &App{router: router, config: settings, middlewares: middlewares}
return &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver}
}
// Router returns the transport contract used by this application.
@@ -84,7 +90,11 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
}
// Config returns the effective application configuration.
func (a *App) Config() config.Config { return a.config }
func (a *App) Config() config.Config {
settings := a.config
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
return settings
}
// Use adds middleware to routes registered after this call.
func (a *App) Use(middleware ...httpx.Middleware) *App {
@@ -123,7 +133,8 @@ func (a *App) Run(address ...string) error {
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 {
if address == "" {
address = a.config.Address
@@ -142,23 +153,21 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
if ctx.Err() != nil {
return a.Close()
}
listenOn := a.config.Address
if listenOn == "" {
listenOn = ":8080"
}
if len(address) > 0 && address[0] != "" {
listenOn = address[0]
}
server := a.NewServer(listenOn)
listener, err := net.Listen("tcp", server.Addr)
serve, err := a.prepareTransport()
if err != nil {
return errors.Join(err, a.Close())
}
return a.serveContext(ctx, server, listener)
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.
// 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())
@@ -166,6 +175,13 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) err
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
if listenOn == "" {
listenOn = ":8080"
@@ -173,12 +189,18 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) err
if len(address) > 0 && address[0] != "" {
listenOn = address[0]
}
return errors.Join(runner.Run(ctx, a, listenOn), a.Close())
return listenOn
}
func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error {
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()
go func() {
if server.TLSConfig != nil {
serveErr <- server.ServeTLS(listener, "", "")
} else {
serveErr <- server.Serve(listener)
}
}()
select {
case err := <-serveErr:

View File

@@ -21,6 +21,9 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.
}
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if len(a.config.TrustedProxies) > 0 {
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
}
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
if err := wrapped(requestContext); err != nil {
a.writeError(requestContext, err)

79
app/transport.go Normal file
View 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
View 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)
})
}
}

View File

@@ -128,3 +128,34 @@ func TestAppUsesConfiguredAddressWhenNoOverrideIsProvided(t *testing.T) {
t.Fatalf("expected configured address %q, got %q", settings.Address, got)
}
}
func TestAppConfigCopiesTrustedProxies(t *testing.T) {
proxies := []string{"127.0.0.1"}
app := New(WithTrustedProxies(proxies...))
proxies[0] = "10.0.0.0/8"
settings := app.Config()
if len(settings.TrustedProxies) != 1 || settings.TrustedProxies[0] != "127.0.0.1" {
t.Fatalf("trusted proxy config was not copied: %+v", settings.TrustedProxies)
}
settings.TrustedProxies[0] = "192.0.2.0/24"
if app.Config().TrustedProxies[0] != "127.0.0.1" {
t.Fatal("App.Config should return a copy")
}
}
func TestAppTrustedProxyClientIPReachesHandlers(t *testing.T) {
app := New(WithTrustedProxies("127.0.0.1"))
app.GET("/client-ip", func(ctx *Context) error { return ctx.OK(ctx.ClientIP()) })
request := httptest.NewRequest(http.MethodGet, "/client-ip", nil)
request.RemoteAddr = "127.0.0.1:443"
request.Header.Set("X-Forwarded-For", "198.51.100.7")
recorder := httptest.NewRecorder()
app.Engine().ServeHTTP(recorder, request)
var response Response
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Data != "198.51.100.7" {
t.Fatalf("expected trusted proxy client IP, got %#v", response.Data)
}
}

View File

@@ -47,6 +47,11 @@ go build -o server ./cmd/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` 限制初始化检查和数据库探针的等待时间。

View File

@@ -4,23 +4,40 @@ import (
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
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.
type Config struct {
Address string
RequestIDHeader string
EnableLogger bool
ShutdownTimeout time.Duration
Log logx.Config
Address string `json:"address"`
Transport string `json:"transport"`
TLS TLSConfig `json:"tls"`
RequestIDHeader string `json:"requestIdHeader"`
EnableLogger bool `json:"enableLogger"`
TrustedProxies []string `json:"trustedProxies"`
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
Log logx.Config `json:"log"`
}
func Default() Config {
return Config{
Address: ":8080",
Transport: TransportNetHTTP,
RequestIDHeader: defaultRequestIDHeader,
ShutdownTimeout: 10 * time.Second,
Log: logx.Default(),
@@ -38,6 +55,27 @@ func (c Config) Validate() error {
if c.ShutdownTimeout <= 0 {
return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"}
}
if _, err := httpx.NewClientIPResolver(c.TrustedProxies...); err != nil {
return &ConfigError{Field: "trusted_proxies", Message: err.Error()}
}
return 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
}
@@ -52,6 +90,16 @@ func (e *ConfigError) Error() string { return "config " + e.Field + " " + e.Mess
// Option changes an App's configuration.
type Option func(*Config)
// WithTransport selects a built-in server. HTTP/2 is experimental and requires TLS.
func WithTransport(name string) Option {
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.
func WithRequestIDHeader(header string) Option {
return func(cfg *Config) {
@@ -66,10 +114,22 @@ func WithLogger(enabled bool) Option {
return func(cfg *Config) { cfg.EnableLogger = enabled }
}
// WithTrustedProxies allows only these IPs/CIDRs to supply X-Forwarded-For.
// An empty list disables forwarding-header trust. Invalid entries are rejected
// by Config.Validate and panic when passed directly to App.New.
func WithTrustedProxies(proxies ...string) Option {
proxies = append([]string(nil), proxies...)
return func(cfg *Config) { cfg.TrustedProxies = append([]string(nil), proxies...) }
}
// WithConfig applies a complete configuration loaded from a file or
// environment. Later options can still override individual fields.
func WithConfig(settings Config) Option {
return func(cfg *Config) { *cfg = settings }
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
return func(cfg *Config) {
*cfg = settings
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
}
}
// WithLogConfig configures the logger used by the application runtime.

View File

@@ -9,6 +9,8 @@ import (
func TestLoadFromEnvUsesDefaults(t *testing.T) {
for _, key := range []string{
"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_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
@@ -20,7 +22,7 @@ func TestLoadFromEnvUsesDefaults(t *testing.T) {
if err != nil {
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)
}
if cfg.EnableLogger || cfg.Log.Level != "info" || cfg.Log.Format != "json" || cfg.Log.Output != "stderr" {
@@ -28,9 +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) {
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090")
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "127.0.0.1, 10.0.0.0/8")
t.Setenv("GO_OMNI_ENABLE_LOGGER", "true")
t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
t.Setenv("GO_OMNI_LOG_FORMAT", "text")
@@ -44,7 +85,7 @@ func TestLoadFromEnvOverridesSettings(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger {
if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger || len(cfg.TrustedProxies) != 2 || cfg.TrustedProxies[1] != "10.0.0.0/8" {
t.Fatalf("unexpected application settings: %+v", cfg)
}
if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource {
@@ -65,6 +106,11 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
}
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "proxy.example.com")
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trusted_proxies") {
t.Fatalf("expected invalid trusted proxy error, got %v", err)
}
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "")
cfg := Default()
cfg.Address = ""
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
@@ -80,11 +126,16 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
func TestWithConfigAndWithLogConfig(t *testing.T) {
settings := Default()
settings.Log.Service = "orders"
settings.TrustedProxies = []string{"127.0.0.1"}
cfg := Default()
WithConfig(settings)(&cfg)
if cfg.Log.Service != "orders" {
if cfg.Log.Service != "orders" || len(cfg.TrustedProxies) != 1 {
t.Fatalf("complete config was not applied: %+v", cfg)
}
settings.TrustedProxies[0] = "10.0.0.0/8"
if cfg.TrustedProxies[0] != "127.0.0.1" {
t.Fatal("WithConfig should copy trusted proxy settings")
}
WithLogConfig(Default().Log)(&cfg)
if cfg.Log.Service != "" {

View File

@@ -19,7 +19,16 @@ func LoadFromEnv(prefix string) (Config, error) {
}
cfg := Default()
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)
if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" {
cfg.TrustedProxies = strings.Split(value, ",")
for i := range cfg.TrustedProxies {
cfg.TrustedProxies[i] = strings.TrimSpace(cfg.TrustedProxies[i])
}
}
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"log"
"os"
"os/signal"
"strings"
@@ -41,28 +42,52 @@ func registerRoutes(application *omni.App, users service.UserRepository, logger
}
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)
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)
if err != nil {
logger.Error("storage initialization failed", "error", err)
os.Exit(1)
return err
}
defer func() {
if err := closeStorage(); err != nil {
logger.Error("storage close failed", "error", err)
}
}()
devToken := strings.TrimSpace(os.Getenv("GO_OMNI_DEV_TOKEN"))
if devToken == "" {
devToken = "dev-token"
@@ -70,16 +95,11 @@ func main() {
authenticator := auth.StaticAuthenticator{
devToken: {UserID: "dev-user", Roles: []string{"admin"}},
}
application := newProtectedApp(users, logger, authenticator)
address := os.Getenv("GO_OMNI_ADDR")
if address == "" {
address = "127.0.0.1:8080"
}
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)
}
application := newProtectedApp(users, logger, authenticator, omni.WithConfig(settings))
application.OnShutdown(logger.Close)
application.OnShutdown(closeStorage)
logger.Info("starting reference API", "address", settings.Address, "transport", settings.Transport, "tls", settings.TLS.CertFile != "", "storage", storage)
return application.RunContext(ctx)
}
func openUsers(ctx context.Context, logger *logx.Logger) (service.UserRepository, func() error, string, error) {

View File

@@ -118,7 +118,7 @@ func TestUserCRUD(t *testing.T) {
}
}
func checkUserCRUD(t *testing.T, app *omni.App) {
func checkUserCRUD(t *testing.T, app http.Handler) {
t.Helper()
health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK"))
if health["status"] != "ok" {

View 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)
}
}

2
go.mod
View File

@@ -5,6 +5,7 @@ go 1.22
require (
github.com/go-playground/validator/v10 v10.20.0
github.com/go-sql-driver/mysql v1.9.3
golang.org/x/net v0.25.0
)
require (
@@ -15,7 +16,6 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
)

105
httpx/clientIP.go Normal file
View 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
View 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
}

View File

@@ -46,6 +46,7 @@ type snapshot struct {
Goroutines int `json:"goroutines"`
Connections int64 `json:"connections"`
Accepted int64 `json:"accepted"`
ActiveRequests int64 `json:"activeRequests,omitempty"`
FileDescriptors int `json:"fileDescriptors,omitempty"`
}
@@ -181,14 +182,26 @@ func serve(adapter string, tlsEnabled bool) error {
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{
@@ -236,11 +249,16 @@ func serve(adapter string, tlsEnabled bool) error {
}
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: tracked.active.Load(), Accepted: tracked.accepted.Load(),
Goroutines: runtime.NumGoroutine(), Connections: connections, Accepted: accepted, ActiveRequests: activeRequests,
FileDescriptors: openFileDescriptorCount(),
}); err != nil {
return err

61
internal/testutil/tls.go Normal file
View 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}
}

106
metrics/metrics.go Normal file
View 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
View 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)
}
}

View File

@@ -2,7 +2,6 @@ package middleware
import (
"fmt"
"net/http"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
@@ -64,19 +63,9 @@ func AccessLog() httpx.Middleware {
"route", httpx.RoutePattern(ctx.Context()),
"status", status,
"durationMs", time.Since(started).Seconds()*1000,
"clientIp", clientIP(ctx.Request()),
"clientIp", ctx.ClientIP(),
)
return err
}
}
}
func clientIP(request *http.Request) string {
if request == nil {
return ""
}
if value := request.Header.Get("X-Forwarded-For"); value != "" {
return value
}
return request.RemoteAddr
}

View File

@@ -55,3 +55,34 @@ func TestAccessLogReportsCommittedStatus(t *testing.T) {
})
}
}
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)
}
}

17
omni.go
View File

@@ -9,6 +9,7 @@ import (
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/metrics"
"git.yuntuo.hk/k2safe/go-omni/response"
)
@@ -24,10 +25,18 @@ type Router = httpx.Router
type HTTPRouterGroup = httpx.RouterGroup
type APIError = apierrors.APIError
type Config = config.Config
type TLSConfig = config.TLSConfig
type Option = config.Option
type Response = response.Response
type LogConfig = logx.Config
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...) }
@@ -39,8 +48,14 @@ func WithRequestIDHeader(header string) Option { return config.WithRequestIDHead
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 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 WithShutdownTimeout(timeout time.Duration) Option { return config.WithShutdownTimeout(timeout) }
@@ -49,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 NewMetrics() *Metrics { return metrics.New() }
func NewError(status int, code, message string) *APIError {
return apierrors.New(status, code, message)
}

View 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": []
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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"
}

View 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
}

View 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

View 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
}

View 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

View 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"
}

File diff suppressed because it is too large Load Diff

View 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

View 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 多协议监听器的独立设计与验收。

View 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"
}

View 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

View 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"
}

View 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

98
scripts/verifyHTTP2.py Executable file
View 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
View 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()

View 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()

View 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()

View File

@@ -24,6 +24,7 @@ def workspace_output(root: Path, value: str) -> tuple[Path, str]:
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")
@@ -70,8 +71,10 @@ def main() -> None:
"-w", "/workspace",
"-e", "GOWORK=off",
"-e", f"GOMAXPROCS={args.gomaxprocs}",
args.image, "sh", "-c", command,
]
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)

313
scripts/verifyProxy.py Normal file
View 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()

View File

@@ -24,12 +24,13 @@ def run(args, cwd, env):
return result.stdout
def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
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)
@@ -85,7 +86,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
if process.poll() is None:
process.kill()
process.wait(timeout=5)
print(f"PASS {binary.name}: HTTP checks, {stopSignal.name}, exit 0, listener closed", flush=True)
print(f"PASS {binary.name} ({transport}): HTTP checks, {stopSignal.name}, exit 0, listener closed", flush=True)
def main():
@@ -95,6 +96,7 @@ def main():
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")
@@ -128,7 +130,7 @@ def main():
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)
exerciseServer(binary, project, env, stopSignal, transport=args.transport)
if not args.skip_run_check:
# Verify the framework's convenient Run API independently from the
@@ -142,7 +144,9 @@ import (
omni "git.yuntuo.hk/k2safe/go-omni"
)
func main() {
app := omni.New()
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) }
@@ -152,7 +156,7 @@ func main() {
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)
exerciseServer(runBinary, project, env, stopSignal, marker, transport=args.transport)
if __name__ == "__main__":

View File

@@ -14,15 +14,21 @@ registerRoutes(application) // 与 omni.New() 复用同一份业务注册代码
`transport/nethttp` 是当前内置的标准库路由实现。它提供一套可移植的路径、方法和错误回退规则;自研网络层可以直接实现同一个 `httpx.Router` 契约。
`transport/http2` 是独立的 opt-in 实验 Runner,负责连接接入、TLS/ALPN、连接上限和全局请求背压,底层帧解析与 stream 流控暂由 `golang.org/x/net/http2` 提供。它不替换 `transport/native`,也不改变默认启动器;只有通过 `App.RunWith(ctx, http2.New(nil), address)` 显式启用才会运行。
`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,不会无限排队。
服务端不是把连接交给一个全局串行 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 调度或协议状态机成为瓶颈时,才需要继续下沉为手写事件循环。
@@ -50,6 +56,10 @@ if err := application.RunWith(ctx, server, ":8443"); err != nil {
需要自己持有 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)
| 项目 | 约定 |

45
transport/http2/PROXY.md Normal file
View 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 默认路径保持现状。

View 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())
}

View 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)
}
}
}

View 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)
}
}
}

View 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)
}

View 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()
}
}

View File

@@ -0,0 +1,459 @@
package http2
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
xhttp2 "golang.org/x/net/http2"
)
// This opt-in suite runs against a real Nginx executable. The Linux wrapper
// builds the test binary separately, then runs it in an isolated Nginx image.
func TestNginxHTTP2Proxy(t *testing.T) {
if os.Getenv("GO_OMNI_HTTP2_PROXY_TEST") != "1" {
t.Skip("use scripts/verifyHTTP2Proxy.py for real Nginx HTTP/2 upstream validation")
}
if output, err := exec.Command("nginx", "-v").CombinedOutput(); err != nil {
t.Fatalf("nginx unavailable: %v: %s", err, output)
} else {
t.Logf("proxyVersion=%s", strings.TrimSpace(string(output)))
}
t.Run("TLSAndConnectionReuse", testProxyTLSAndReuse)
t.Run("TimeoutAndCancellation", testProxyTimeoutAndCancellation)
t.Run("GoAwayAndRollingDrain", testProxyGoAwayAndDrain)
}
type proxyReply struct {
Instance string `json:"instance"`
RequestID string `json:"requestId"`
Protocol string `json:"protocol"`
ALPN string `json:"alpn"`
Method string `json:"method"`
BodyBytes int `json:"bodyBytes"`
BodySHA256 string `json:"bodySha256"`
}
type proxyOrigin struct {
server *Server
address string
done chan struct{}
serveErr error
requests atomic.Uint64
duplicates atomic.Uint64
seen sync.Map
}
func newProxyOrigin(t *testing.T, certificate tls.Certificate, instance string, beforeReply func(*http.Request)) *proxyOrigin {
t.Helper()
o := &proxyOrigin{done: make(chan struct{})}
o.server = New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.ProtoMajor != 2 || request.TLS == nil || request.TLS.NegotiatedProtocol != "h2" || request.TLS.ServerName != "localhost" {
t.Error("proxy upstream did not negotiate verified-name TLS/h2")
http.Error(writer, "wrong upstream protocol", http.StatusInternalServerError)
return
}
o.requests.Add(1)
id := request.Header.Get("X-Request-ID")
if _, loaded := o.seen.LoadOrStore(id, struct{}{}); loaded {
o.duplicates.Add(1)
}
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(writer, "body read failed", http.StatusBadRequest)
return
}
if beforeReply != nil {
beforeReply(request)
}
if request.Context().Err() != nil {
return
}
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("X-Request-ID", id)
_ = json.NewEncoder(writer).Encode(proxyReply{
Instance: instance, RequestID: id, Protocol: request.Proto, ALPN: request.TLS.NegotiatedProtocol,
Method: request.Method, BodyBytes: len(body), BodySHA256: fmt.Sprintf("%x", sha256.Sum256(body)),
})
}))
o.server.MaxConnections = 16
o.server.MaxConcurrentRequests = 32
o.server.MaxConcurrentStreams = 32
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
o.address = listener.Addr().String()
go func() {
o.serveErr = o.server.ServeTLS(context.Background(), listener, &tls.Config{
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
})
close(o.done)
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := o.shutdown(ctx); err != nil {
t.Errorf("origin cleanup: %v", err)
}
stats := o.server.Stats()
if stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || o.duplicates.Load() != 0 {
t.Errorf("origin lifecycle/duplicate failure: stats=%+v duplicates=%d", stats, o.duplicates.Load())
}
})
return o
}
func (o *proxyOrigin) shutdown(ctx context.Context) error {
if err := o.server.Shutdown(ctx); err != nil {
return err
}
select {
case <-o.done:
return o.serveErr
case <-ctx.Done():
return ctx.Err()
}
}
func testProxyTLSAndReuse(t *testing.T) {
certificate, roots := testCertificate(t)
origin := newProxyOrigin(t, certificate, "primary", nil)
endpoint := startTestNginx(t, certificate, origin.address, "")
client := proxyClient(t, roots)
for index := 0; index < 32; index++ {
payload := []byte(fmt.Sprintf("payload-%d", index))
result, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodPost, fmt.Sprintf("reuse-%d", index), payload, 200)
if err != nil || result.Instance != "primary" {
t.Fatalf("reuse request %d: %+v %v", index, result, err)
}
}
stats := origin.server.Stats()
if stats.AcceptedConnections != 1 || stats.ActiveConnections != 1 || origin.requests.Load() != 32 {
t.Fatalf("upstream TLS connection was not reused: %+v requests=%d", stats, origin.requests.Load())
}
for _, path := range []string{"/badTrust", "/badName"} {
if _, err := proxyCall(context.Background(), client, endpoint+path, http.MethodGet, path, nil, 502); err != nil {
t.Fatal(err)
}
}
if origin.requests.Load() != 32 {
t.Fatal("an upstream with invalid certificate trust or hostname reached the handler")
}
if _, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "afterTLSFailures", nil, 200); err != nil {
t.Fatal(err)
}
t.Logf("proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true")
}
func testProxyTimeoutAndCancellation(t *testing.T) {
certificate, roots := testCertificate(t)
started, canceled := make(chan string, 2), make(chan string, 2)
origin := newProxyOrigin(t, certificate, "primary", func(request *http.Request) {
if request.URL.Path == "/timeout" || request.URL.Path == "/cancel" {
started <- request.URL.Path
<-request.Context().Done()
canceled <- request.URL.Path
}
})
endpoint := startTestNginx(t, certificate, origin.address, "")
client := proxyClient(t, roots)
begin := time.Now()
if _, err := proxyCall(context.Background(), client, endpoint+"/timeout", http.MethodGet, "timeout", nil, 504); err != nil {
t.Fatal(err)
}
if time.Since(begin) > 2*time.Second {
t.Fatal("upstream timeout was not bounded")
}
awaitProxyPath(t, started, "/timeout")
awaitProxyPath(t, canceled, "/timeout")
waitHTTP2Stats(t, origin.server, func(stats Stats) bool { return stats.ActiveRequests == 0 })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
_, err := proxyCall(ctx, client, endpoint+"/cancel", http.MethodGet, "cancel", nil, 200)
result <- err
}()
awaitProxyPath(t, started, "/cancel")
cancel()
if err := serveResult(t, result); err == nil {
t.Fatal("canceled proxy request unexpectedly succeeded")
}
awaitProxyPath(t, canceled, "/cancel")
waitHTTP2Stats(t, origin.server, func(stats Stats) bool { return stats.ActiveRequests == 0 })
if _, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "afterCancellation", nil, 200); err != nil {
t.Fatal(err)
}
t.Logf("proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats=%+v", origin.server.Stats())
}
func testProxyGoAwayAndDrain(t *testing.T) {
certificate, roots := testCertificate(t)
started := make(chan context.Context, 1)
release := make(chan struct{})
var releaseOnce sync.Once
releaseOld := func() { releaseOnce.Do(func() { close(release) }) }
defer releaseOld()
old := newProxyOrigin(t, certificate, "old", func(request *http.Request) {
if request.URL.Path == "/hold" {
started <- request.Context()
select {
case <-release:
case <-request.Context().Done():
}
}
})
next := newProxyOrigin(t, certificate, "new", nil)
endpoint := startTestNginx(t, certificate, old.address, next.address)
client := proxyClient(t, roots)
if reply, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "beforeRollout", nil, 200); err != nil || reply.Instance != "old" {
t.Fatalf("initial request: %+v %v", reply, err)
}
held := make(chan error, 1)
go func() {
reply, err := proxyCall(context.Background(), client, endpoint+"/hold", http.MethodPost, "heldOld", []byte("finish exactly once"), 200)
if err == nil && reply.Instance != "old" {
err = fmt.Errorf("held request switched instance: %+v", reply)
}
held <- err
}()
var heldContext context.Context
select {
case heldContext = <-started:
case <-time.After(3 * time.Second):
t.Fatal("held request never reached old instance")
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
stopped := make(chan error, 1)
go func() { stopped <- old.shutdown(ctx) }()
waitHTTP2Stats(t, old.server, func(stats Stats) bool { return stats.GoAwayFrames > 0 })
if heldContext.Err() != nil {
t.Fatal("GOAWAY canceled the in-flight request")
}
// The primary listener is closed, so Nginx must route new requests to the
// backup while the old HTTP/2 stream is still draining. POSTs also prove
// request bodies and IDs survive this transition without duplicate handlers.
for index := 0; index < 20; index++ {
reply, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodPost, fmt.Sprintf("rollout-%d", index), []byte(fmt.Sprint(index)), 200)
if err != nil || reply.Instance != "new" {
t.Fatalf("request during drain %d: %+v %v", index, reply, err)
}
}
select {
case err := <-held:
t.Fatalf("held request completed before release: %v", err)
default:
}
if old.requests.Load() != 2 || next.requests.Load() != 20 {
t.Fatalf("rollout request accounting: old=%d new=%d", old.requests.Load(), next.requests.Load())
}
releaseOld()
if err := serveResult(t, held); err != nil {
t.Fatal(err)
}
if err := serveResult(t, stopped); err != nil {
t.Fatal(err)
}
stats := old.server.Stats()
if stats.ActiveRequests != 0 || stats.ActiveConnections != 0 || stats.GoAwayFrames == 0 {
t.Fatalf("old instance failed to drain: %+v", stats)
}
t.Logf("proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats=%+v", stats)
}
func awaitProxyPath(t *testing.T, events <-chan string, expected string) {
t.Helper()
select {
case path := <-events:
if path != expected {
t.Fatalf("handler event: got %s, want %s", path, expected)
}
case <-time.After(3 * time.Second):
t.Fatalf("timed out waiting for handler event: %s", expected)
}
}
func proxyClient(t *testing.T, roots *x509.CertPool) *http.Client {
t.Helper()
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12, NextProtos: []string{"h2"},
}, StrictMaxConcurrentStreams: true}
t.Cleanup(transport.CloseIdleConnections)
return &http.Client{Transport: transport, Timeout: 10 * time.Second}
}
func proxyCall(ctx context.Context, client *http.Client, endpoint, method, id string, body []byte, expectedStatus int) (proxyReply, error) {
var reply proxyReply
request, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return reply, err
}
request.Header.Set("X-Request-ID", id)
response, err := client.Do(request)
if err != nil {
return reply, err
}
defer response.Body.Close()
data, err := io.ReadAll(io.LimitReader(response.Body, 8192))
if err != nil {
return reply, err
}
if response.StatusCode != expectedStatus || response.ProtoMajor != 2 || response.TLS == nil || response.TLS.NegotiatedProtocol != "h2" || len(response.TLS.VerifiedChains) == 0 {
return reply, fmt.Errorf("unverified edge TLS/h2 or status: %s body=%q", response.Status, data)
}
if expectedStatus != 200 {
return reply, nil
}
if err := json.Unmarshal(data, &reply); err != nil {
return reply, err
}
if reply.RequestID != id || response.Header.Get("X-Request-ID") != id || reply.Protocol != "HTTP/2.0" || reply.ALPN != "h2" || reply.Method != method || reply.BodyBytes != len(body) || reply.BodySHA256 != fmt.Sprintf("%x", sha256.Sum256(body)) {
return reply, fmt.Errorf("upstream protocol, request ID or body mismatch: %+v", reply)
}
return reply, nil
}
func startTestNginx(t *testing.T, certificate tls.Certificate, primary, backup string) string {
t.Helper()
directory := t.TempDir()
certFile, keyFile := filepath.Join(directory, "cert.pem"), filepath.Join(directory, "key.pem")
writeProxyFile(t, certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}))
key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey)
if err != nil {
t.Fatal(err)
}
writeProxyFile(t, keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}))
wrong, _ := testCertificate(t)
wrongFile := filepath.Join(directory, "wrong.pem")
writeProxyFile(t, wrongFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: wrong.Certificate[0]}))
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
_ = listener.Close()
backupLine := ""
if backup != "" {
backupLine = "server " + backup + " backup;"
}
config := fmt.Sprintf(`daemon off;
master_process off;
pid %q;
error_log stderr info;
events { worker_connections 128; }
http {
access_log off;
client_body_temp_path %q;
proxy_temp_path %q;
fastcgi_temp_path %q;
uwsgi_temp_path %q;
scgi_temp_path %q;
upstream origins { server %s max_fails=1 fail_timeout=30s; %s keepalive 16; }
upstream badTrust { server %s; }
upstream badName { server %s; }
server {
listen %s ssl;
http2 on;
ssl_certificate %q;
ssl_certificate_key %q;
proxy_http_version 2;
proxy_set_header Connection "";
proxy_set_header X-Request-ID $http_x_request_id;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate %q;
proxy_ssl_server_name on;
proxy_ssl_name localhost;
proxy_connect_timeout 500ms;
proxy_send_timeout 2s;
proxy_read_timeout 10s;
proxy_buffering off;
proxy_request_buffering off;
proxy_next_upstream error timeout;
proxy_next_upstream_tries 2;
location / { proxy_pass https://origins; }
location = /timeout { proxy_read_timeout 250ms; proxy_next_upstream off; proxy_pass https://origins; }
location = /badTrust { proxy_ssl_trusted_certificate %q; proxy_next_upstream off; proxy_pass https://badTrust; }
location = /badName { proxy_ssl_name wrong.invalid; proxy_next_upstream off; proxy_pass https://badName; }
}
}
`, filepath.Join(directory, "nginx.pid"), filepath.Join(directory, "clientBody"), filepath.Join(directory, "proxyBody"),
filepath.Join(directory, "fastcgi"), filepath.Join(directory, "uwsgi"), filepath.Join(directory, "scgi"),
primary, backupLine, primary, primary, address, certFile, keyFile, certFile, wrongFile)
configFile := filepath.Join(directory, "nginx.conf")
writeProxyFile(t, configFile, []byte(config))
logFile := filepath.Join(directory, "nginx.log")
output, err := os.Create(logFile)
if err != nil {
t.Fatal(err)
}
command := exec.Command("nginx", "-p", directory+"/", "-c", configFile)
command.Stdout, command.Stderr = output, output
if err := command.Start(); err != nil {
_ = output.Close()
t.Fatal(err)
}
done := make(chan struct{})
var processErr error
go func() { processErr = command.Wait(); close(done) }()
t.Cleanup(func() {
_ = command.Process.Signal(syscall.SIGQUIT)
select {
case <-done:
case <-time.After(3 * time.Second):
_ = command.Process.Kill()
<-done
t.Error("nginx did not exit after SIGQUIT")
}
_ = output.Close()
if processErr != nil || t.Failed() {
log, _ := os.ReadFile(logFile)
t.Logf("nginx exit=%v log:\n%s", processErr, log)
}
if processErr != nil {
t.Errorf("nginx exit: %v", processErr)
}
})
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
select {
case <-done:
t.Fatalf("nginx exited during startup: %v", processErr)
default:
}
conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
if err == nil {
_ = conn.Close()
return "https://" + address
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("nginx did not start")
return ""
}
func writeProxyFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
}

497
transport/http2/server.go Normal file
View File

@@ -0,0 +1,497 @@
// Package http2 provides an opt-in HTTP/2 transport experiment.
//
// It owns the listener, TLS/ALPN setup, connection admission and global
// request backpressure while using x/net/http2 for frame parsing and stream
// flow control. It is deliberately separate from transport/native, whose
// production preview remains HTTP/1.1.
package http2
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
xhttp2 "golang.org/x/net/http2"
)
const (
defaultShutdownTimeout = 10 * time.Second
defaultTLSHandshake = 10 * time.Second
defaultMaxBodyBytes = 8 << 20
http2ALPNProtocol = "h2"
)
// Server is an opt-in HTTP/2 transport. Run uses cleartext h2c for local
// experiments; RunTLS or ServeTLS should be used for deployed TLS traffic.
// HTTP/2 stream limits and global request admission are independent: a client
// may open many streams, but only admitted handlers run concurrently.
type Server struct {
MaxConnections int
MaxConcurrentRequests int
MaxConcurrentStreams uint32
MaxHeaderBytes int
MaxBodyBytes int64
IdleTimeout time.Duration
TLSHandshakeTimeout time.Duration
ShutdownTimeout time.Duration
TLSConfig *tls.Config
mu sync.Mutex
handler http.Handler
listener net.Listener
shutdownConfig *http.Server
shutdownDone chan struct{}
shutdownOnce sync.Once
closing bool
cancel context.CancelFunc
conns map[net.Conn]struct{}
protocolConns map[net.Conn]struct{}
connSlots chan struct{}
requestSlots chan struct{}
accepted atomic.Uint64
rejectedConns atomic.Uint64
rejectedReqs atomic.Uint64
protocolErrors atomic.Uint64
goAwaySent atomic.Uint64
rstStreams atomic.Uint64
goAwayFrames atomic.Uint64
activeConns atomic.Int64
activeRequests atomic.Int64
wg sync.WaitGroup
}
// Stats is a point-in-time view of HTTP/2 transport admission and lifecycle.
// Accepted and rejected counters are monotonic; active counters are gauges.
type Stats struct {
AcceptedConnections uint64 `json:"acceptedConnections"`
RejectedConnections uint64 `json:"rejectedConnections"`
RejectedRequests uint64 `json:"rejectedRequests"`
ProtocolErrors uint64 `json:"protocolErrors"`
GoAwaySent uint64 `json:"goAwaySent"`
RSTStreams uint64 `json:"rstStreams"`
GoAwayFrames uint64 `json:"goAwayFrames"`
ActiveConnections int64 `json:"activeConnections"`
ActiveRequests int64 `json:"activeRequests"`
}
// New creates an HTTP/2 transport with the supplied handler. The handler may
// also be supplied by App.RunWith through the Runner interface.
func New(handler http.Handler) *Server {
return &Server{
handler: handler,
conns: make(map[net.Conn]struct{}),
MaxBodyBytes: defaultMaxBodyBytes,
}
}
// Stats returns a lock-free snapshot suitable for a metrics handler.
func (s *Server) Stats() Stats {
if s == nil {
return Stats{}
}
return Stats{
AcceptedConnections: s.accepted.Load(),
RejectedConnections: s.rejectedConns.Load(),
RejectedRequests: s.rejectedReqs.Load(),
ProtocolErrors: s.protocolErrors.Load(),
GoAwaySent: s.goAwaySent.Load(),
RSTStreams: s.rstStreams.Load(),
GoAwayFrames: s.goAwayFrames.Load(),
ActiveConnections: s.activeConns.Load(),
ActiveRequests: s.activeRequests.Load(),
}
}
// Run implements app.Runner. Without TLSConfig it starts an h2c listener for
// local testing. Use RunTLS or TLSConfig for real HTTPS traffic.
func (s *Server) Run(ctx context.Context, handler http.Handler, address string) error {
if handler != nil {
s.handler = handler
}
if s.handler == nil {
return errors.New("go-omni/http2: nil handler")
}
if address == "" {
address = ":8080"
}
listener, err := net.Listen("tcp", address)
if err != nil {
return err
}
if s.TLSConfig != nil {
return s.ServeTLS(ctx, listener, s.TLSConfig)
}
return s.Serve(ctx, listener)
}
// RunTLS loads a certificate pair and serves HTTP/2 over TLS with ALPN h2.
func (s *Server) RunTLS(ctx context.Context, handler http.Handler, address, certFile, keyFile string) error {
if handler != nil {
s.handler = handler
}
if s.handler == nil {
return errors.New("go-omni/http2: nil handler")
}
if address == "" {
address = ":8443"
}
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return err
}
listener, err := net.Listen("tcp", address)
if err != nil {
return err
}
return s.ServeTLS(ctx, listener, &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12})
}
// ServeTLS wraps listener with TLS and requires ALPN h2. A caller-provided
// config is cloned, so the original value is never mutated.
func (s *Server) ServeTLS(ctx context.Context, listener net.Listener, config *tls.Config) error {
if listener == nil {
return errors.New("go-omni/http2: nil listener")
}
if config == nil {
return errors.New("go-omni/http2: nil TLS config")
}
prepared, err := prepareTLSConfig(config)
if err != nil {
_ = listener.Close()
return err
}
return s.Serve(ctx, tls.NewListener(listener, prepared))
}
func prepareTLSConfig(config *tls.Config) (*tls.Config, error) {
clone := config.Clone()
if len(clone.NextProtos) > 0 {
for _, protocol := range clone.NextProtos {
if protocol != http2ALPNProtocol {
return nil, fmt.Errorf("go-omni/http2: unsupported TLS ALPN protocol %q; only %q is supported", protocol, http2ALPNProtocol)
}
}
}
clone.NextProtos = []string{http2ALPNProtocol}
return clone, nil
}
// Serve accepts h2c connections from listener until ctx is canceled.
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
if listener == nil {
return errors.New("go-omni/http2: nil listener")
}
if s.handler == nil {
return errors.New("go-omni/http2: nil handler")
}
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
if s.cancel != nil {
s.mu.Unlock()
return errors.New("go-omni/http2: server is already running")
}
// Keep the connection context independent from the caller's context. A
// canceled parent starts graceful shutdown; it must not cancel active
// streams before the GOAWAY/drain phase has completed.
baseCtx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.listener = listener
s.shutdownDone = make(chan struct{})
s.shutdownOnce = sync.Once{}
s.closing = false
if s.conns == nil {
s.conns = make(map[net.Conn]struct{})
}
if s.protocolConns == nil {
s.protocolConns = make(map[net.Conn]struct{})
}
if s.MaxConnections > 0 {
s.connSlots = make(chan struct{}, s.MaxConnections)
} else {
s.connSlots = nil
}
if s.MaxConcurrentRequests > 0 {
s.requestSlots = make(chan struct{}, s.MaxConcurrentRequests)
} else {
s.requestSlots = nil
}
s.mu.Unlock()
maxBodyBytes := s.MaxBodyBytes
if maxBodyBytes <= 0 {
maxBodyBytes = defaultMaxBodyBytes
}
requestHandler := http.Handler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.ContentLength > maxBodyBytes {
_ = request.Body.Close()
http.Error(writer, "request body too large\n", http.StatusRequestEntityTooLarge)
return
}
request.Body = http.MaxBytesReader(writer, request.Body, maxBodyBytes)
s.handler.ServeHTTP(writer, request)
}))
gate := &requestGate{server: s, next: requestHandler, slots: s.requestSlots}
protocolServer := &xhttp2.Server{
MaxConcurrentStreams: s.MaxConcurrentStreams,
IdleTimeout: s.IdleTimeout,
CountError: func(string) {
s.protocolErrors.Add(1)
},
}
shutdownConfig := &http.Server{IdleTimeout: s.IdleTimeout, MaxHeaderBytes: s.MaxHeaderBytes}
if err := xhttp2.ConfigureServer(shutdownConfig, protocolServer); err != nil {
cancel()
s.mu.Lock()
s.listener = nil
s.cancel = nil
s.mu.Unlock()
return err
}
s.mu.Lock()
s.shutdownConfig = shutdownConfig
s.mu.Unlock()
contextWakeDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
// Wake Accept. Serve performs the graceful drain synchronously so
// its return value reports a shutdown timeout to the caller.
_ = listener.Close()
case <-contextWakeDone:
}
}()
defer func() {
close(contextWakeDone)
s.mu.Lock()
closing := s.closing
shutdownDone := s.shutdownDone
s.listener = nil
s.shutdownConfig = nil
s.cancel = nil
s.mu.Unlock()
if !closing {
cancel()
} else if shutdownDone != nil {
<-shutdownDone
}
}()
for {
conn, err := listener.Accept()
if err != nil {
if ctx.Err() != nil || s.isClosing() {
break
}
return err
}
s.accepted.Add(1)
if slots := s.connSlots; slots != nil {
select {
case slots <- struct{}{}:
default:
s.rejectedConns.Add(1)
_ = conn.Close()
continue
}
}
s.mu.Lock()
if s.closing {
s.mu.Unlock()
if s.connSlots != nil {
<-s.connSlots
}
_ = conn.Close()
continue
}
s.conns[conn] = struct{}{}
s.activeConns.Add(1)
s.wg.Add(1)
s.mu.Unlock()
go s.serveConn(baseCtx, protocolServer, shutdownConfig, gate, conn)
}
if ctx.Err() != nil && !s.isClosing() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), s.shutdownTimeout())
err := s.Shutdown(shutdownCtx)
shutdownCancel()
return err
}
return nil
}
func (s *Server) shutdownTimeout() time.Duration {
if s.ShutdownTimeout > 0 {
return s.ShutdownTimeout
}
return defaultShutdownTimeout
}
func (s *Server) serveConn(ctx context.Context, protocolServer *xhttp2.Server, baseConfig *http.Server, handler http.Handler, conn net.Conn) {
defer s.wg.Done()
defer func() {
s.mu.Lock()
delete(s.conns, conn)
delete(s.protocolConns, conn)
s.activeConns.Add(-1)
s.mu.Unlock()
if s.connSlots != nil {
<-s.connSlots
}
_ = conn.Close()
}()
if tlsConn, ok := conn.(*tls.Conn); ok {
_ = conn.SetDeadline(time.Now().Add(s.tlsHandshakeTimeout()))
if err := tlsConn.HandshakeContext(ctx); err != nil {
return
}
_ = conn.SetDeadline(time.Time{})
}
s.mu.Lock()
if s.closing {
s.mu.Unlock()
return
}
s.protocolConns[conn] = struct{}{}
s.mu.Unlock()
observed := &observedConn{
Conn: conn,
readParser: frameParser{prefaceRemain: 24},
onFrame: func(direction frameDirection, frameType byte) {
if direction == frameRead && frameType == frameTypeRSTStream {
s.rstStreams.Add(1)
}
if direction == frameWrite && frameType == frameTypeGoAway {
s.goAwayFrames.Add(1)
}
},
}
var protocolConn net.Conn = observed
if tlsConn, ok := conn.(*tls.Conn); ok {
protocolConn = &observedTLSConn{observedConn: observed, state: tlsConn.ConnectionState()}
}
protocolServer.ServeConn(protocolConn, &xhttp2.ServeConnOpts{
Context: ctx, Handler: handler, BaseConfig: baseConfig,
})
}
func (s *Server) tlsHandshakeTimeout() time.Duration {
if s.TLSHandshakeTimeout > 0 {
return s.TLSHandshakeTimeout
}
return defaultTLSHandshake
}
// Shutdown stops accepting connections and waits for active HTTP/2 sessions.
// If the context expires, remaining connections are closed so handlers can
// observe cancellation through their request contexts.
func (s *Server) Shutdown(ctx context.Context) error {
if s == nil {
return nil
}
if ctx == nil {
timeout := s.ShutdownTimeout
if timeout <= 0 {
timeout = defaultShutdownTimeout
}
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), timeout)
defer cancel()
}
s.mu.Lock()
if s.cancel == nil {
s.mu.Unlock()
return nil
}
alreadyClosing := s.closing
s.closing = true
listener := s.listener
shutdownConfig := s.shutdownConfig
cancel := s.cancel
connections := make([]net.Conn, 0, len(s.conns))
for conn := range s.conns {
connections = append(connections, conn)
}
protocolConnectionCount := len(s.protocolConns)
s.mu.Unlock()
if listener != nil {
_ = listener.Close()
}
// x/net/http2's configured shutdown callback sends GOAWAY and lets active
// streams finish. The base request context remains live until the timeout
// path below, where connections are forcibly closed.
if shutdownConfig != nil && !alreadyClosing {
s.goAwaySent.Add(uint64(protocolConnectionCount))
_ = shutdownConfig.Shutdown(context.Background())
}
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
cancel()
s.finishShutdown()
return nil
case <-ctx.Done():
for _, conn := range connections {
_ = conn.Close()
}
cancel()
s.finishShutdown()
return ctx.Err()
}
}
func (s *Server) finishShutdown() {
s.mu.Lock()
done := s.shutdownDone
s.mu.Unlock()
if done != nil {
s.shutdownOnce.Do(func() { close(done) })
}
}
func (s *Server) isClosing() bool {
s.mu.Lock()
closing := s.closing
s.mu.Unlock()
return closing
}
type requestGate struct {
server *Server
next http.Handler
slots chan struct{}
}
func (g *requestGate) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if g.slots != nil {
select {
case g.slots <- struct{}{}:
g.server.activeRequests.Add(1)
defer func() {
<-g.slots
g.server.activeRequests.Add(-1)
}()
default:
g.server.rejectedReqs.Add(1)
http.Error(writer, "server busy\n", http.StatusServiceUnavailable)
return
}
} else {
g.server.activeRequests.Add(1)
defer g.server.activeRequests.Add(-1)
}
g.next.ServeHTTP(writer, request)
}

View File

@@ -0,0 +1,453 @@
package http2
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
xhttp2 "golang.org/x/net/http2"
)
func TestServerHandlesH2CRequest(t *testing.T) {
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.ProtoMajor != 2 {
http.Error(writer, "not HTTP/2", http.StatusInternalServerError)
return
}
_, _ = writer.Write([]byte("ok"))
}))
address, stop := startServer(t, server)
defer stop()
transport := &xhttp2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
},
}
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
defer transport.CloseIdleConnections()
response, err := client.Get("http://" + address + "/health")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %d", response.StatusCode)
}
if stats := server.Stats(); stats.AcceptedConnections != 1 {
t.Fatalf("unexpected HTTP/2 stats: %+v", stats)
}
}
func TestServerEnforcesGlobalRequestLimitAndRecovers(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/hold" {
once.Do(func() { close(started) })
<-release
}
_, _ = writer.Write([]byte("ok"))
}))
server.MaxConcurrentRequests = 1
address, stop := startServer(t, server)
defer stop()
transport := &xhttp2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
},
}
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
defer transport.CloseIdleConnections()
firstDone := make(chan *http.Response, 1)
firstErr := make(chan error, 1)
go func() {
response, err := client.Get("http://" + address + "/hold")
if err != nil {
firstErr <- err
return
}
firstDone <- response
}()
select {
case <-started:
case err := <-firstErr:
t.Fatal(err)
case <-time.After(time.Second):
t.Fatal("first HTTP/2 stream did not start")
}
response, err := client.Get("http://" + address + "/busy")
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("expected 503 at stream limit, got %d", response.StatusCode)
}
if stats := server.Stats(); stats.RejectedRequests != 1 {
t.Fatalf("expected one rejected stream, got %+v", stats)
}
close(release)
select {
case response = <-firstDone:
response.Body.Close()
case err := <-firstErr:
t.Fatal(err)
case <-time.After(time.Second):
t.Fatal("held HTTP/2 stream did not finish")
}
response, err = client.Get("http://" + address + "/recovered")
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("request limit did not recover, got %d", response.StatusCode)
}
}
func TestServerRejectsBodyAboveLimitAndRecovers(t *testing.T) {
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(writer, err.Error(), http.StatusRequestEntityTooLarge)
return
}
_, _ = writer.Write(body)
}))
server.MaxBodyBytes = 4
address, stop := startServer(t, server)
defer stop()
transport := &xhttp2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
},
}
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
defer transport.CloseIdleConnections()
request, err := http.NewRequest(http.MethodPost, "http://"+address+"/too-large", bytes.NewReader([]byte("12345")))
if err != nil {
t.Fatal(err)
}
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("expected 413 for oversized body, got %d", response.StatusCode)
}
request, err = http.NewRequest(http.MethodPost, "http://"+address+"/ok", bytes.NewReader([]byte("1234")))
if err != nil {
t.Fatal(err)
}
response, err = client.Do(request)
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "1234" {
t.Fatalf("body limit did not recover: status=%d body=%q err=%v", response.StatusCode, body, readErr)
}
}
func TestServerRejectsHeadersAboveLimitAndRecovers(t *testing.T) {
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = io.WriteString(writer, "ok")
}))
server.MaxHeaderBytes = 1024
address, stop := startServer(t, server)
defer stop()
transport := &xhttp2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
},
}
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
defer transport.CloseIdleConnections()
request, err := http.NewRequest(http.MethodGet, "http://"+address+"/too-large", nil)
if err != nil {
t.Fatal(err)
}
request.Header.Set("X-Large", strings.Repeat("x", 2048))
response, err := client.Do(request)
if err == nil {
response.Body.Close()
if response.StatusCode < http.StatusBadRequest {
t.Fatalf("oversized headers unexpectedly succeeded with status %d", response.StatusCode)
}
}
response, err = client.Get("http://" + address + "/recovered")
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "ok" {
t.Fatalf("header limit did not recover: status=%d body=%q err=%v", response.StatusCode, body, readErr)
}
}
func TestServerMultiplexes128Streams(t *testing.T) {
started := make(chan struct{}, 128)
release := make(chan struct{})
var active, maximum atomic.Int32
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
current := active.Add(1)
for {
seen := maximum.Load()
if current <= seen || maximum.CompareAndSwap(seen, current) {
break
}
}
started <- struct{}{}
<-release
active.Add(-1)
_, _ = io.WriteString(writer, request.URL.Path)
}))
server.MaxConcurrentStreams = 128
address, stop := startServer(t, server)
defer stop()
transport := &xhttp2.Transport{
AllowHTTP: true,
StrictMaxConcurrentStreams: true,
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
},
}
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
defer transport.CloseIdleConnections()
type multiplexedResponse struct {
response *http.Response
expected string
}
responses := make(chan multiplexedResponse, 128)
errorsCh := make(chan error, 128)
for i := 0; i < 128; i++ {
go func(i int) {
response, err := client.Get(fmt.Sprintf("http://%s/stream-%d", address, i))
if err != nil {
errorsCh <- err
return
}
responses <- multiplexedResponse{response: response, expected: fmt.Sprintf("/stream-%d", i)}
}(i)
}
for i := 0; i < 128; i++ {
select {
case <-started:
case err := <-errorsCh:
close(release)
t.Fatal(err)
case <-time.After(5 * time.Second):
close(release)
t.Fatal("HTTP/2 streams did not reach the handler")
}
}
if got := maximum.Load(); got < 128 {
close(release)
t.Fatalf("expected 128 concurrent streams, got %d", got)
}
if stats := server.Stats(); stats.ActiveConnections != 1 {
close(release)
t.Fatalf("expected one multiplexed connection, got %+v", stats)
}
close(release)
for i := 0; i < 128; i++ {
select {
case result := <-responses:
body, err := io.ReadAll(result.response.Body)
result.response.Body.Close()
if err != nil || result.response.StatusCode != http.StatusOK || string(body) != result.expected {
t.Fatalf("unexpected multiplexed response: status=%d body=%q expected=%q err=%v", result.response.StatusCode, body, result.expected, err)
}
case err := <-errorsCh:
t.Fatal(err)
case <-time.After(5 * time.Second):
t.Fatal("HTTP/2 streams did not finish")
}
}
}
func TestServerStreamsLargeBodyWithFlowControl(t *testing.T) {
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
var total int64
buffer := make([]byte, 16<<10)
for {
n, err := request.Body.Read(buffer)
total += int64(n)
if n > 0 {
time.Sleep(time.Millisecond)
}
if err == io.EOF {
break
}
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
}
_, _ = fmt.Fprintf(writer, "%d", total)
}))
server.MaxBodyBytes = 4 << 20
address, stop := startServer(t, server)
defer stop()
transport := &xhttp2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
},
}
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
defer transport.CloseIdleConnections()
payload := bytes.Repeat([]byte("x"), 2<<20)
request, err := http.NewRequest(http.MethodPost, "http://"+address+"/large", bytes.NewReader(payload))
if err != nil {
t.Fatal(err)
}
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "2097152" {
t.Fatalf("large body flow-control result: status=%d body=%q err=%v", response.StatusCode, body, readErr)
}
}
func TestServerTLSUsesH2ALPN(t *testing.T) {
certificate, roots := testCertificate(t)
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.TLS == nil || request.TLS.NegotiatedProtocol != "h2" {
http.Error(writer, "missing h2 ALPN", http.StatusInternalServerError)
return
}
writer.WriteHeader(http.StatusNoContent)
}))
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
})
}()
stop := func() {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("shutdown: %v", err)
}
if err := <-done; err != nil {
t.Errorf("serve: %v", err)
}
}
defer stop()
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2"},
}}
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
defer transport.CloseIdleConnections()
response, err := client.Get("https://" + listener.Addr().String() + "/health")
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("unexpected TLS status: %d", response.StatusCode)
}
}
func startServer(t *testing.T, server *Server) (string, func()) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- server.Serve(context.Background(), listener) }()
stop := func() {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("shutdown: %v", err)
}
if err := <-done; err != nil {
t.Errorf("serve: %v", err)
}
}
return listener.Addr().String(), stop
}
func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
leaf, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
certificate := tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: key,
}
certificate.Leaf = leaf
roots := x509.NewCertPool()
roots.AddCert(leaf)
return certificate, roots
}

View File

@@ -0,0 +1,220 @@
package http2
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
xhttp2 "golang.org/x/net/http2"
)
// TestTLSSoak is short by default so it remains part of normal package
// verification. Linux acceptance runs set GO_OMNI_HTTP2_SOAK_DURATION=30m.
func TestTLSSoak(t *testing.T) {
started := time.Now()
duration := soakDuration(t)
logSoakResources(t, started, "before", 0, 0, Stats{})
certificate, roots := testCertificate(t)
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/slow":
timer := time.NewTimer(10 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
case <-request.Context().Done():
return
}
case "/body":
if _, err := io.Copy(io.Discard, request.Body); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
}
writer.Header().Set("X-Soak-Request-Id", request.Header.Get("X-Soak-Request-Id"))
_, _ = io.WriteString(writer, request.URL.Path)
}))
server.MaxConnections = 8
server.MaxConcurrentRequests = 128
server.MaxConcurrentStreams = 128
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12,
})
}()
transport := &xhttp2.Transport{
TLSClientConfig: &tls.Config{
RootCAs: roots,
ServerName: "localhost",
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2"},
},
StrictMaxConcurrentStreams: true,
}
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
var requests, failures atomic.Uint64
var firstFailure string
var failureOnce sync.Once
recordFailure := func(message string) {
failures.Add(1)
failureOnce.Do(func() { firstFailure = message })
}
monitorStop, monitorDone := make(chan struct{}), make(chan struct{})
go func() {
defer close(monitorDone)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
logSoakResources(t, started, "running", requests.Load(), failures.Load(), server.Stats())
case <-monitorStop:
return
}
}
}()
defer func() {
close(monitorStop)
<-monitorDone
}()
deadline := time.Now().Add(duration)
var workers sync.WaitGroup
for worker := 0; worker < 128; worker++ {
workers.Add(1)
go func(worker int) {
defer workers.Done()
for sequence := uint64(0); time.Now().Before(deadline); sequence++ {
path := "/health"
method := http.MethodGet
var body io.Reader
if worker%32 == 0 {
path = "/slow"
}
if worker%64 == 0 {
path = "/body"
method = http.MethodPost
body = &repeatingReader{remaining: 64 << 10}
}
requestContext, cancelRequest := context.WithTimeout(context.Background(), 2*time.Second)
request, err := http.NewRequestWithContext(requestContext, method, "https://"+listener.Addr().String()+path, body)
if err != nil {
cancelRequest()
recordFailure(err.Error())
continue
}
requestID := strconv.Itoa(worker) + ":" + strconv.FormatUint(sequence, 10)
request.Header.Set("X-Soak-Request-Id", requestID)
response, err := client.Do(request)
if err != nil {
cancelRequest()
recordFailure(err.Error())
continue
}
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1024))
response.Body.Close()
cancelRequest()
if readErr != nil {
recordFailure(readErr.Error())
continue
}
if response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || string(responseBody) != path || response.Header.Get("X-Soak-Request-Id") != requestID {
recordFailure("unexpected status, protocol, body or request ID: " + response.Status)
continue
}
requests.Add(1)
}
}(worker)
}
workers.Wait()
transport.CloseIdleConnections()
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr := server.Shutdown(shutdownContext)
cancel()
if shutdownErr != nil {
t.Fatalf("TLS soak shutdown: %v", shutdownErr)
}
if err := <-done; err != nil {
t.Fatalf("TLS soak serve: %v", err)
}
logSoakResources(t, started, "after", requests.Load(), failures.Load(), server.Stats())
if failures.Load() != 0 || requests.Load() == 0 {
t.Fatalf("TLS soak requests=%d failures=%d firstFailure=%q stats=%+v", requests.Load(), failures.Load(), firstFailure, server.Stats())
}
if stats := server.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 {
t.Fatalf("TLS soak protocol errors, unexpected rejection or unreclaimed resources: %+v", stats)
}
}
func soakDuration(t *testing.T) time.Duration {
t.Helper()
if value := os.Getenv("GO_OMNI_HTTP2_SOAK_DURATION"); value != "" {
duration, err := time.ParseDuration(value)
if err != nil || duration <= 0 {
t.Fatalf("invalid GO_OMNI_HTTP2_SOAK_DURATION %q: %v", value, err)
}
return duration
}
return 3 * time.Second
}
// These measurements cover the client and server in the same test process.
// RSS includes race runtime allocations that do not appear in Go heap stats.
func logSoakResources(t *testing.T, started time.Time, phase string, requests, failures uint64, stats Stats) {
t.Helper()
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
rssBytes, fileDescriptors := int64(-1), -1
if data, err := os.ReadFile("/proc/self/statm"); err == nil {
if fields := strings.Fields(string(data)); len(fields) >= 2 {
if pages, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
rssBytes = pages * int64(os.Getpagesize())
}
}
}
if entries, err := os.ReadDir("/proc/self/fd"); err == nil {
fileDescriptors = len(entries)
}
t.Logf("phase=%s elapsed=%s requests=%d failures=%d heapBytes=%d rssBytes=%d goroutines=%d fileDescriptors=%d stats=%+v",
phase, time.Since(started).Round(time.Millisecond), requests, failures,
memory.HeapAlloc, rssBytes, runtime.NumGoroutine(), fileDescriptors, stats)
}
type repeatingReader struct {
remaining int
}
func (r *repeatingReader) Read(buffer []byte) (int, error) {
if r.remaining == 0 {
return 0, io.EOF
}
if len(buffer) > r.remaining {
buffer = buffer[:r.remaining]
}
for index := range buffer {
buffer[index] = 'x'
}
r.remaining -= len(buffer)
return len(buffer), nil
}
func (r *repeatingReader) Close() error { return nil }

45
transport/native/HTTP2.md Normal file
View File

@@ -0,0 +1,45 @@
# native HTTP/2 进入条件
当前 `transport/native` 是经过 Linux 长连接和反向代理验收的 HTTP/1.1 实现。HTTP/2 暂不直接打开,原因是它把一个 TCP 连接变成多个独立请求流,连接级的 Keep-Alive、超时和并发限制不能直接复用现有 HTTP/1.1 状态机。
当前已提供独立的 `transport/http2` 实验 Runner。它已经验证 TLS/h2、h2c、本地连接和请求闸门、GOAWAY 排空以及 RST_STREAM 取消;这些结果用于验证生命周期设计,不代表 native 已经启用 HTTP/2。实验 Runner 还覆盖了默认 1 MiB 头部上限、8 MiB 请求体上限、128 路同连接 stream 多路复用,以及 2 MiB 慢读取 body 在窗口耗尽后的恢复。`TestTLSSoak` 默认运行 3 秒,Linux 长测通过 `python3 scripts/verifyHTTP2.py --duration 30m` 启动。
2026-09-23 已在 Linux amd64 完成单 TLS 连接、128 个 worker 的 30 分钟长测:58,102,476 次请求,0 失败与协议错误,采样 RSS 峰值约 22.6 MiB,退出后连接、活动请求和 FD 回收。测试容器限制为 2 核、2 GiB,测量包含同一进程中的客户端与服务端,见 [报告](../../performance/http2SoakLinux.json)。该场景包含慢 Handler 与 64 KiB body。
同日完成多连接慢客户端混合场景:8 条独立 TLS 连接,每条 14 个普通请求 worker、1 个慢上传 worker、1 个慢下载 worker,持续 30 分钟。256 KiB 上传每 4 KiB 等待 2 ms;8 MiB 下载每次最多读取 64 KiB 后等待 20 ms。共完成 53,191,687 次请求,其中慢上传 106,971 次、慢下载 5,588 次,0 失败与协议错误;每条连接均覆盖三类请求且没有重连。采样 RSS 峰值约 116.6 MiB,包含客户端、服务端与下载缓冲;退出后活动连接/请求归零、goroutine 回到 2、FD 回到 6,释放测试数据并两次 GC 后存活堆约 450 KiB。运行命令为 `python3 scripts/verifyHTTP2.py --scenario mixed --duration 30m`,完整采样见 [混合长测报告](../../performance/http2MixedSoakLinux.json)。8 MiB 下载超过客户端的 stream 初始窗口,需要窗口更新,但不代表耗尽了连接级窗口;后者由下述独立帧测试验证。Nginx HTTP/2 回源联调结果见下方。
连接级流控及关闭边界测试已通过 Linux amd64 的 HTTP/2 全包 race、vet、build 回归,测试提交、执行命令和原始日志校验值见 [边界回归报告](../../performance/http2FlowControlLinux.json)。
Nginx 1.30.5 的真实 HTTP/2 回源联调也已通过:普通与 race 模式各 3 轮,覆盖两段 TLS/h2、证书拒绝、连接复用、超时/取消恢复和 GOAWAY 下的新旧实例切换。完整配置边界、运行方法与报告见 [HTTP/2 代理回源验收](../http2/PROXY.md)。
## 目标边界
- HTTP/2 必须作为显式 opt-in 协议,不改变 `omni.New()`、`transport/nethttp` 或 native HTTP/1.1 的默认行为。
- TLS ALPN 只在 HTTP/2 实现通过全部验收后增加 `h2`;当前 native 只宣告 `http/1.1`,误配 `h2` 会在启动时失败。
- Handler、Service、统一响应、requestId、可信代理和业务日志继续复用现有 `httpx.Context` 与 `http.Handler` 边界。
- h2c 只用于本地测试,生产入口使用 TLS + ALPN;不能把明文升级路径默认为公网协议。
## 连接和流的生命周期
1. accept loop 先执行连接上限检查,再执行 TLS 握手;握手受独立超时约束,不能占满连接槽位后无限等待。
2. TLS 协商得到 `h2` 后进入 HTTP/2 connection state;其他 ALPN 直接关闭,不能把 HTTP/2 preface 交给 HTTP/1.1 解析器。
3. 每个 stream 创建独立的 `http.Request` 和取消 context。`RST_STREAM` 只取消当前请求,TCP 断开才取消该连接上的所有请求。
4. `MaxConcurrentRequests` 是所有连接共享的全局请求闸门;`MaxConcurrentStreams` 限制单连接并发流;两者都必须在收到完整请求体前生效。
5. `MaxHeaderBytes` 沿用标准库默认 1 MiB 上限,`MaxBodyBytes` 默认限制请求体为 8 MiB;已声明超限的请求在进入业务 handler 前返回 413,未声明长度的 body 通过受限 reader 读取,避免业务层无意间把整个请求体加载到内存。
6. 服务关闭时停止 accept,发送 GOAWAY 拒绝新流,等待活动流排空;超过 `ShutdownTimeout` 后关闭连接并取消剩余请求。实验 Runner 已覆盖这条顺序。
## 必须可观测的状态
HTTP/2 版本需要在 native `Stats` 之外至少记录:活动 stream、被拒绝 stream、连接级 protocol error、RST_STREAM、GOAWAY 发送次数,以及因流控或并发闸门触发的拒绝。实验 Runner 当前已通过 `ActiveRequests`、`RejectedRequests`、`ProtocolErrors`、`RSTStreams`、`GoAwaySent` 和 `GoAwayFrames` 暴露活动/拒绝/协议错误/客户端重置/优雅关闭快照;帧观察器只解析 9 字节头部并跳过 payload,不会缓存大 body。计数必须是原子快照,不能在 metrics handler 中锁住连接状态机。
## 进入实现前的验收矩阵
- TLS ALPN:只协商 `h2`,HTTP/1.1 客户端仍由现有 native 路径处理;不支持的 ALPN 在握手前后都不会误入错误解析器。
- 多路复用:一个连接并发至少 128 个 stream,响应和 requestId 不串线,在流与连接级发送额度充足时,单个慢 stream 不阻塞其他可发送 stream(实验 Runner 已覆盖 128 路同连接 stream)。
- 流控:慢读取、慢写、窗口耗尽和大 body 都能恢复;内存不会随未消费 body 无限增长。实验 Runner 已覆盖 2 MiB 慢读取 body 和请求体上限,以及客户端将 stream 窗口设为零时的大响应阻塞、同连接 204、其他连接带 body 响应、WINDOW_UPDATE 恢复发送和 RST_STREAM 取消回收;连接级独立测试将初始 stream 窗口设为 128 KiB,仅消耗默认 65,535 字节连接窗口,确认暂停与隔离,连接级 WINDOW_UPDATE 后恢复并复用连接;持续慢客户端混合长测也已通过。连接级窗口耗尽时,同连接的其他 DATA 也受该窗口约束,HEADERS 和其他 TCP 连接仍可继续。
- 取消:RST_STREAM、客户端断开、服务关闭分别只影响应受影响的请求,并能在 metrics 中看到回收;当前实验 Runner 已覆盖 RST_STREAM 与服务关闭,包括客户端零接收窗口造成写阻塞时,关闭超时强制解除写阻塞、取消请求并回收连接。
- 背压:连接上限、全局 stream 上限、单连接 stream 上限和 header/body 上限均能快速拒绝并自动恢复。实验 Runner 已增加 TLS 未完成握手占满连接槽位时的及时拒绝、握手超时回收及后续正常 HTTPS 请求恢复验收。
- 代理联调:Nginx 的 TLS/h2 回源、Keep-Alive、超时/取消及 GOAWAY 排空已由 `verifyHTTP2Proxy.py` 验证。旧实例处理中的 POST 能正常完成,新请求转入备用实例,旧实例连接与请求归零。此测试使用单进程 Nginx,未验证 Nginx master/worker 重载或 Cloudflare 实网部署。`verifyProxy.py` 继续承担 native HTTP/1.1 TLS 回源验收。
- 长测:Linux amd64 至少 30 分钟 TLS、多连接、多 stream、慢客户端混合场景;要求 0 协议错误、0 请求串线,连接、stream、goroutine 和 FD 在停止后回收。单连接场景与 8 连接慢上传/慢下载混合场景均已通过,原始日志与采样分别保存在两个报告中。
统一启动配置已接入 `omni.New`:`GO_OMNI_TRANSPORT=http2` 加证书和私钥可显式选择实验适配器,业务路由不需要改变;native 仍只协商 HTTP/1.1。参考业务增加了三种适配器的真实网络验证与排空/超时回归。下一步是业务环境试用、native 的 HTTP/1.1 与 HTTP/2 多协议监听器设计和接入回归,不能仅通过添加 `h2` 到 native 的 TLS ALPN 就宣告接入完成。需要成熟 HTTP/2 入口时可使用 `transport/nethttp` 或代理终止后回源 HTTP/1.1。

View File

@@ -12,6 +12,7 @@ import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -33,17 +34,20 @@ const (
defaultMaxBodyBytes = 8 << 20
defaultReadBufferSize = 4 << 10
maxPooledResponseBytes = 64 << 10
nativeALPNProtocol = "http/1.1"
)
// Server is a small HTTP/1.1 server with project-owned connection lifecycle.
// It supports keep-alive, graceful shutdown, bounded concurrency and standard
// http.Handler values. Zero MaxConnections and MaxConcurrentRequests mean
// unlimited; pooled protocol objects are reused automatically.
// A Server owns one serving lifecycle; create a new Server to restart.
type Server struct {
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
ReadBufferSize int
MaxHeaderBytes int
MaxBodyBytes int64
@@ -53,18 +57,49 @@ type Server struct {
// when the listener and TLS configuration are assembled by the caller.
TLSConfig *tls.Config
mu sync.Mutex
handler http.Handler
listener net.Listener
conns map[*connection]struct{}
closing bool
cancel context.CancelFunc
wg sync.WaitGroup
connSlots chan struct{}
requestSlots chan struct{}
readerPool sync.Pool
writerPool sync.Pool
connectionPool sync.Pool
mu sync.Mutex
handler http.Handler
listener net.Listener
conns map[*connection]struct{}
closing bool
cancel context.CancelFunc
wg sync.WaitGroup
connSlots chan struct{}
requestSlots chan struct{}
accepted atomic.Uint64
rejectedConns atomic.Uint64
rejectedRequests atomic.Uint64
activeConns atomic.Int64
activeRequests atomic.Int64
readerPool sync.Pool
writerPool sync.Pool
connectionPool sync.Pool
}
// Stats is a point-in-time view of the native server lifecycle. Counters are
// monotonic unless their name starts with Active.
type Stats struct {
AcceptedConnections uint64 `json:"acceptedConnections"`
// Rejections count admission decisions, even if writing a 503 fails.
RejectedConnections uint64 `json:"rejectedConnections"`
RejectedRequests uint64 `json:"rejectedRequests"`
ActiveConnections int64 `json:"activeConnections"`
ActiveRequests int64 `json:"activeRequests"`
}
// Stats returns native connection and request counters without taking the
// server lifecycle mutex, so it is safe to call from a metrics endpoint.
func (s *Server) Stats() Stats {
if s == nil {
return Stats{}
}
return Stats{
AcceptedConnections: s.accepted.Load(),
RejectedConnections: s.rejectedConns.Load(),
RejectedRequests: s.rejectedRequests.Load(),
ActiveConnections: s.activeConns.Load(),
ActiveRequests: s.activeRequests.Load(),
}
}
type connection struct {
@@ -73,6 +108,7 @@ type connection struct {
active atomic.Bool
closeWhenIdle atomic.Bool
closed atomic.Bool
counted bool
slotHeld bool
closeDone chan struct{}
}
@@ -162,9 +198,27 @@ func (s *Server) ServeTLS(ctx context.Context, listener net.Listener, config *tl
if config == nil {
return errors.New("go-omni/native: nil TLS config")
}
config, err := prepareTLSConfig(config)
if err != nil {
_ = listener.Close()
return err
}
return s.Serve(ctx, tls.NewListener(listener, config))
}
func prepareTLSConfig(config *tls.Config) (*tls.Config, error) {
clone := config.Clone()
if len(clone.NextProtos) == 0 {
clone.NextProtos = []string{nativeALPNProtocol}
}
for _, protocol := range clone.NextProtos {
if protocol != nativeALPNProtocol {
return nil, fmt.Errorf("go-omni/native: unsupported TLS ALPN protocol %q; only %q is supported", protocol, nativeALPNProtocol)
}
}
return clone, nil
}
// Serve accepts connections from listener until the context is canceled or an
// unrecoverable accept error occurs. The listener is owned by the Server.
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
@@ -179,9 +233,9 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
}
s.mu.Lock()
if s.listener != nil {
if s.cancel != nil {
s.mu.Unlock()
return errors.New("go-omni/native: server is already running")
return errors.New("go-omni/native: server has already been started")
}
s.listener = listener
s.closing = false
@@ -200,15 +254,14 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
s.mu.Unlock()
defer cancel()
shutdownDone := make(chan struct{})
stopWatcher, watcherDone := make(chan struct{}), make(chan struct{})
go func() {
defer close(watcherDone)
select {
case <-ctx.Done():
timeout := s.shutdownTimeout()
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
_ = s.Shutdown(shutdownCtx)
cancel()
case <-shutdownDone:
// Wake Accept; the serving goroutine owns draining and its error.
_ = listener.Close()
case <-stopWatcher:
}
}()
@@ -222,10 +275,12 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
serveErr = err
break
}
s.accepted.Add(1)
if slots := s.connSlots; slots != nil {
select {
case slots <- struct{}{}:
default:
s.rejectedConns.Add(1)
_ = conn.Close()
continue
}
@@ -236,8 +291,10 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
state.active.Store(false)
state.closeWhenIdle.Store(false)
state.closed.Store(false)
state.counted = true
state.slotHeld = s.connSlots != nil
state.closeDone = make(chan struct{})
s.activeConns.Add(1)
s.mu.Lock()
if s.closing {
s.mu.Unlock()
@@ -251,20 +308,8 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
go s.serveConnection(baseCtx, state)
}
close(shutdownDone)
s.mu.Lock()
if s.listener == listener {
s.listener = nil
}
s.mu.Unlock()
if ctx.Err() != nil || s.isClosing() {
timeout := s.shutdownTimeout()
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
shutdownErr := s.Shutdown(shutdownCtx)
cancel()
return errors.Join(shutdownErr, serveErr)
}
_ = listener.Close()
close(stopWatcher)
<-watcherDone
timeout := s.shutdownTimeout()
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
shutdownErr := s.Shutdown(shutdownCtx)
@@ -272,17 +317,15 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
return errors.Join(shutdownErr, serveErr)
}
// Shutdown stops accepting requests, cancels active request contexts and waits
// for connections to finish. On timeout, active sockets are closed.
// Shutdown stops accepting requests and waits for active handlers to finish.
// On timeout, request contexts are canceled and active sockets are closed.
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
s.closing = true
if s.cancel != nil {
s.cancel()
}
cancelRequests := s.cancel
listener := s.listener
states := make([]*connection, 0, len(s.conns))
for state := range s.conns {
@@ -309,15 +352,22 @@ func (s *Server) Shutdown(ctx context.Context) error {
case <-done:
return nil
case <-ctx.Done():
if cancelRequests != nil {
cancelRequests()
}
for _, state := range states {
state.close()
}
<-done
// Handlers must cooperate with cancellation. Waiting here would make
// an uncooperative handler defeat the caller's shutdown deadline.
return ctx.Err()
}
}
func (s *Server) shutdownTimeout() time.Duration {
if s.ShutdownTimeout > 0 {
return s.ShutdownTimeout
}
if value := s.WriteTimeout; value > 0 {
return value
}
@@ -376,9 +426,16 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
}
_ = state.conn.SetReadDeadline(time.Time{})
firstRequest = false
s.mu.Lock()
if s.closing || state.closed.Load() {
s.mu.Unlock()
return
}
state.active.Store(true)
s.mu.Unlock()
requestContext, cancel := context.WithCancel(baseCtx)
request = request.WithContext(requestContext)
state.active.Store(true)
s.activeRequests.Add(1)
writer := s.writerPool.Get().(*responseWriter)
writer.reset(request.Method)
writer.configure(request, state.conn, closeAfter, s.WriteTimeout, cancel)
@@ -391,9 +448,10 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
cancel()
writer.reset("")
s.writerPool.Put(writer)
state.active.Store(false)
s.finishRequest(state)
return
default:
s.rejectedRequests.Add(1)
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusServiceUnavailable)
writer.body.WriteString("server busy\n")
@@ -402,7 +460,7 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
cancel()
writer.reset("")
s.writerPool.Put(writer)
state.active.Store(false)
closing = s.finishRequest(state) || closing
if closeAfter || closing {
return
}
@@ -410,19 +468,56 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
}
}
handler := s.handler
disconnectWatch := newClientDisconnectWatch(reader, state.conn, cancel)
var disconnectWatch *clientDisconnectWatch
if body, ok := request.Body.(*requestBody); ok {
// A body reader and the disconnect watcher must never read the
// bufio.Reader concurrently. The body starts the watcher once it
// reaches EOF; body read errors cancel the request directly.
body.cancel = cancel
body.onComplete = func() {
disconnectWatch = newClientDisconnectWatch(reader, state.conn, cancel)
}
} else {
disconnectWatch = newClientDisconnectWatch(reader, state.conn, cancel)
}
handler.ServeHTTP(writer, request)
disconnectWatch.stop()
bodyErr := request.Body.Close()
if disconnectWatch != nil {
disconnectWatch.stop()
}
cancel()
closing := s.isClosing() || state.closeWhenIdle.Load()
closing := s.isClosing() || state.closeWhenIdle.Load() || bodyErr != nil
if bodyErr != nil {
var protocolErr *protocolError
if !errors.As(bodyErr, &protocolErr) {
if requestSlot {
<-s.requestSlots
}
writer.reset("")
s.writerPool.Put(writer)
s.finishRequest(state)
return
}
// Request bodies are streamed into the handler, so a malformed
// chunk or an over-limit body can be discovered while draining
// after the handler returns. Replace the buffered response with
// the same protocol error that eager parsing used to return.
if !writer.streaming {
writer.reset(request.Method)
writer.configure(request, state.conn, true, s.WriteTimeout, cancel)
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(protocolErr.status)
writer.body.WriteString(protocolErr.message + "\n")
}
}
if err := writer.writeTo(state.conn, closeAfter || closing, s.WriteTimeout); err != nil {
if requestSlot {
<-s.requestSlots
}
writer.reset("")
s.writerPool.Put(writer)
state.active.Store(false)
s.finishRequest(state)
return
}
if requestSlot {
@@ -431,13 +526,25 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
shouldClose := closeAfter || writer.closeAfter || closing
writer.reset("")
s.writerPool.Put(writer)
state.active.Store(false)
shouldClose = s.finishRequest(state) || shouldClose
if shouldClose {
return
}
}
}
// Publish the idle transition under the same lock that starts shutdown. Either
// Shutdown observes an idle connection and closes it, or this request observes
// shutdown and closes it instead of starting another Keep-Alive read.
func (s *Server) finishRequest(state *connection) bool {
s.mu.Lock()
state.active.Store(false)
closing := s.closing || state.closeWhenIdle.Load()
s.mu.Unlock()
s.activeRequests.Add(-1)
return closing
}
func (c *connection) close() {
if c.closed.CompareAndSwap(false, true) {
defer close(c.closeDone)
@@ -445,6 +552,10 @@ func (c *connection) close() {
c.server.mu.Lock()
delete(c.server.conns, c)
c.server.mu.Unlock()
if c.counted {
c.server.activeConns.Add(-1)
c.counted = false
}
}
}
@@ -577,27 +688,23 @@ func readRequest(reader *bufio.Reader, conn net.Conn, maxHeaderBytes int, maxBod
if !chunked && contentLength > maxBodyBytes {
return nil, true, &protocolError{status: http.StatusRequestEntityTooLarge, message: "request body too large"}
}
var body []byte
var body io.ReadCloser = http.NoBody
var trailer http.Header
if chunked {
body, trailer, err = readChunkedBody(reader, maxBodyBytes, setBodyDeadline, readLine)
if err != nil {
return nil, true, err
trailer = make(http.Header)
body = &requestBody{
reader: reader, maxBytes: maxBodyBytes, chunked: true,
trailer: trailer, setDeadline: setBodyDeadline, readLine: readLine,
}
// net/http uses -1 to indicate that the decoded length was not known
// from the request headers. The body is already buffered for the
// handler, but retaining this value keeps the request contract
// compatible with the standard server.
// from the request headers.
contentLength = -1
} else {
body = make([]byte, contentLength)
if contentLength > 0 {
if setBodyDeadline != nil {
setBodyDeadline()
}
if _, err := io.ReadFull(reader, body); err != nil {
return nil, true, err
}
body = &requestBody{reader: reader, remaining: contentLength, setDeadline: setBodyDeadline}
}
}
request := &http.Request{
@@ -607,7 +714,7 @@ func readRequest(reader *bufio.Reader, conn net.Conn, maxHeaderBytes int, maxBod
ProtoMajor: protoMajor,
ProtoMinor: protoMinor,
Header: header,
Body: io.NopCloser(bytes.NewReader(body)),
Body: body,
ContentLength: contentLength,
Host: header.Get("Host"),
RequestURI: target,
@@ -627,78 +734,199 @@ func readRequest(reader *bufio.Reader, conn net.Conn, maxHeaderBytes int, maxBod
return request, closeAfter, nil
}
func readChunkedBody(reader *bufio.Reader, maxBodyBytes int64, setBodyDeadline func(), readLine func() (string, error)) ([]byte, http.Header, error) {
var body bytes.Buffer
initialCapacity := int64(64 << 10)
if maxBodyBytes < initialCapacity {
initialCapacity = maxBodyBytes
}
if initialCapacity > 0 && initialCapacity <= int64(int(^uint(0)>>1)) {
body.Grow(int(initialCapacity))
}
type requestBody struct {
reader *bufio.Reader
remaining int64
maxBytes int64
readBytes int64
chunked bool
chunkRemain int64
needSuffix bool
trailer http.Header
readLine func() (string, error)
setDeadline func()
started bool
cancel context.CancelFunc
onComplete func()
done bool
closed bool
err error
}
func (b *requestBody) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if b.err != nil {
return 0, b.err
}
if b.done {
return 0, io.EOF
}
if !b.chunked {
return b.readContentLength(p)
}
return b.readChunked(p)
}
func (b *requestBody) readContentLength(p []byte) (int, error) {
if b.remaining == 0 {
b.complete()
return 0, io.EOF
}
if !b.started {
b.started = true
if b.setDeadline != nil {
b.setDeadline()
}
}
if int64(len(p)) > b.remaining {
p = p[:b.remaining]
}
n, err := b.reader.Read(p)
b.remaining -= int64(n)
b.readBytes += int64(n)
if b.remaining == 0 {
b.complete()
if n > 0 {
return n, nil
}
return 0, io.EOF
}
if err != nil {
b.fail(err)
}
return n, err
}
func (b *requestBody) readChunked(p []byte) (int, error) {
for {
line, err := readLine()
if err != nil {
return nil, nil, err
}
chunkSizeText := line
if extension := strings.IndexByte(chunkSizeText, ';'); extension >= 0 {
chunkSizeText = chunkSizeText[:extension]
}
chunkSizeText = strings.TrimSpace(chunkSizeText)
if chunkSizeText == "" || !validHexNumber(chunkSizeText) {
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "invalid chunk size"}
}
chunkSize, parseErr := strconv.ParseUint(chunkSizeText, 16, 63)
if parseErr != nil {
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "invalid chunk size"}
}
if chunkSize == 0 {
trailer := make(http.Header)
for {
line, err = readLine()
if err != nil {
return nil, nil, err
if b.chunkRemain == 0 {
if b.needSuffix {
var suffix [2]byte
if _, err := io.ReadFull(b.reader, suffix[:]); err != nil {
b.fail(err)
return 0, err
}
if line == "" {
return body.Bytes(), trailer, nil
if suffix[0] != '\r' || suffix[1] != '\n' {
err := &protocolError{status: http.StatusBadRequest, message: "malformed chunk terminator"}
b.fail(err)
return 0, err
}
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "folded trailers are not supported"}
}
index := strings.IndexByte(line, ':')
if index <= 0 {
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "malformed trailer"}
}
name := strings.TrimSpace(line[:index])
value := strings.TrimSpace(line[index+1:])
if !validToken(name) || !validHeaderValue(value) {
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "malformed trailer"}
}
trailer.Add(name, value)
b.needSuffix = false
}
if err := b.beginChunk(); err != nil {
b.fail(err)
return 0, err
}
if b.done {
return 0, io.EOF
}
}
if int64(body.Len()) > maxBodyBytes-int64(chunkSize) {
return nil, nil, &protocolError{status: http.StatusRequestEntityTooLarge, message: "request body too large"}
if int64(len(p)) > b.chunkRemain {
p = p[:b.chunkRemain]
}
if setBodyDeadline != nil {
setBodyDeadline()
n, err := b.reader.Read(p)
b.chunkRemain -= int64(n)
b.readBytes += int64(n)
if b.chunkRemain == 0 {
b.needSuffix = true
}
if _, err := io.CopyN(&body, reader, int64(chunkSize)); err != nil {
return nil, nil, err
if n > 0 {
if err != nil && b.chunkRemain > 0 {
b.fail(err)
return n, err
}
return n, nil
}
if setBodyDeadline != nil {
setBodyDeadline()
if err != nil {
b.fail(err)
return 0, err
}
var suffix [2]byte
if _, err := io.ReadFull(reader, suffix[:]); err != nil {
return nil, nil, err
}
if suffix[0] != '\r' || suffix[1] != '\n' {
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "malformed chunk terminator"}
}
}
func (b *requestBody) beginChunk() error {
if b.setDeadline != nil {
b.setDeadline()
}
line, err := b.readLine()
if err != nil {
return err
}
chunkSizeText := line
if extension := strings.IndexByte(chunkSizeText, ';'); extension >= 0 {
chunkSizeText = chunkSizeText[:extension]
}
chunkSizeText = strings.TrimSpace(chunkSizeText)
if chunkSizeText == "" || !validHexNumber(chunkSizeText) {
return &protocolError{status: http.StatusBadRequest, message: "invalid chunk size"}
}
chunkSize, parseErr := strconv.ParseUint(chunkSizeText, 16, 63)
if parseErr != nil {
return &protocolError{status: http.StatusBadRequest, message: "invalid chunk size"}
}
if chunkSize == 0 {
for {
line, err = b.readLine()
if err != nil {
return err
}
if line == "" {
b.complete()
return nil
}
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
return &protocolError{status: http.StatusBadRequest, message: "folded trailers are not supported"}
}
index := strings.IndexByte(line, ':')
if index <= 0 {
return &protocolError{status: http.StatusBadRequest, message: "malformed trailer"}
}
name := strings.TrimSpace(line[:index])
value := strings.TrimSpace(line[index+1:])
if !validToken(name) || !validHeaderValue(value) {
return &protocolError{status: http.StatusBadRequest, message: "malformed trailer"}
}
b.trailer.Add(name, value)
}
}
if int64(chunkSize) > b.maxBytes-b.readBytes {
return &protocolError{status: http.StatusRequestEntityTooLarge, message: "request body too large"}
}
b.chunkRemain = int64(chunkSize)
return nil
}
func (b *requestBody) Close() error {
if b.closed {
return b.err
}
b.closed = true
if !b.done && b.err == nil {
_, _ = io.Copy(io.Discard, b)
}
return b.err
}
func (b *requestBody) complete() {
if b.done {
return
}
b.done = true
if b.onComplete != nil {
b.onComplete()
}
}
func (b *requestBody) fail(err error) {
if b.err == nil {
b.err = err
}
b.done = true
if b.cancel != nil {
b.cancel()
}
}
func validHexNumber(value string) bool {

View File

@@ -107,6 +107,47 @@ func TestServerRejectsMalformedBodyAndLimits(t *testing.T) {
}
}
func TestServerStreamsContentLengthBodyToHandler(t *testing.T) {
entered := make(chan struct{})
application := omni.NewWithRouter(netrouter.New())
application.POST("/body", func(ctx *omni.Context) error {
close(entered)
body, err := io.ReadAll(ctx.Request().Body)
if err != nil {
return err
}
return ctx.OK(map[string]string{"body": string(body)})
})
server, listener, done := startServer(t, application)
defer stopServer(t, server, listener, done)
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := io.WriteString(conn, "POST /body HTTP/1.1\r\nHost: test\r\nContent-Length: 5\r\n\r\nhel"); err != nil {
t.Fatal(err)
}
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("handler did not start before the complete body arrived")
}
if _, err := io.WriteString(conn, "lo"); err != nil {
t.Fatal(err)
}
response, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodPost})
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil || response.StatusCode != http.StatusOK || !strings.Contains(string(body), `"body":"hello"`) {
t.Fatalf("unexpected streamed body response: status=%d body=%s error=%v", response.StatusCode, body, err)
}
}
func TestServerChunkedBodyAndTrailers(t *testing.T) {
application := omni.NewWithRouter(netrouter.New())
application.POST("/body", func(ctx *omni.Context) error {
@@ -154,11 +195,18 @@ func TestServerChunkedBodyLimit(t *testing.T) {
}
}
func TestServerSlowBodyTimesOutBeforeHandler(t *testing.T) {
func TestServerSlowBodyTimesOutWithoutSuccessfulHandler(t *testing.T) {
var handlerCalls atomic.Int32
application := omni.NewWithRouter(netrouter.New())
application.POST("/body", func(ctx *omni.Context) error {
body, err := io.ReadAll(ctx.Request().Body)
if err != nil {
return err
}
handlerCalls.Add(1)
if len(body) != 4 {
return fmt.Errorf("unexpected body length %d", len(body))
}
return ctx.OK("ok")
})
server := native.New(application)
@@ -178,14 +226,10 @@ func TestServerSlowBodyTimesOutBeforeHandler(t *testing.T) {
t.Fatal(err)
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(time.Second))
_, _ = io.WriteString(conn, "POST /body HTTP/1.1\r\nHost: test\r\nContent-Length: 4\r\n\r\na")
buffer := make([]byte, 1)
if _, err := conn.Read(buffer); err == nil {
t.Fatal("expected slow request body to time out and close")
}
requirePeerClose(t, conn)
if handlerCalls.Load() != 0 {
t.Fatal("handler ran before the request body was complete")
t.Fatal("handler reported success before the request body was complete")
}
}
@@ -460,12 +504,13 @@ func (b *closeBody) Close() error {
return errors.Join(err, b.conn.Close())
}
func TestServerShutdownCancelsActiveHandler(t *testing.T) {
started := make(chan struct{})
func TestServerShutdownTimeoutCancelsActiveHandler(t *testing.T) {
started, canceled := make(chan struct{}), make(chan struct{})
application := omni.NewWithRouter(netrouter.New())
application.GET("/wait", func(ctx *omni.Context) error {
close(started)
<-ctx.Context().Done()
close(canceled)
return ctx.Context().Err()
})
server, listener, done := startServer(t, application)
@@ -480,19 +525,12 @@ func TestServerShutdownCancelsActiveHandler(t *testing.T) {
case <-time.After(2 * time.Second):
t.Fatal("handler did not start")
}
shutdownContext, cancel := context.WithTimeout(context.Background(), time.Second)
shutdownContext, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
t.Fatal(err)
}
response, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected canceled handler to become 500, got %d", response.StatusCode)
if err := server.Shutdown(shutdownContext); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected shutdown deadline, got %v", err)
}
<-canceled
conn.Close()
select {
case err := <-done:
@@ -667,6 +705,9 @@ func TestServerConcurrentRequestLimit(t *testing.T) {
if response.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("expected 503 at concurrency limit, got %d", response.StatusCode)
}
if stats := server.Stats(); stats.RejectedRequests != 1 {
t.Fatalf("expected one rejected request, got %+v", stats)
}
close(release)
response, err = http.ReadResponse(bufio.NewReader(first), &http.Request{Method: http.MethodGet})
if err != nil {
@@ -678,6 +719,90 @@ func TestServerConcurrentRequestLimit(t *testing.T) {
}
}
func TestServerConcurrentRequestLimitRecovers(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var first sync.Once
application := omni.NewWithRouter(netrouter.New())
application.GET("/work", func(ctx *omni.Context) error {
first.Do(func() {
close(started)
<-release
})
return ctx.OK("done")
})
server := native.New(application)
server.MaxConcurrentRequests = 1
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- server.Serve(context.Background(), listener) }()
waitForServer(t, listener.Addr().String())
defer stopServer(t, server, listener, done)
firstConn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer firstConn.Close()
_, _ = io.WriteString(firstConn, "GET /work HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("first handler did not start")
}
secondConn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(secondConn, "GET /work HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
response, err := http.ReadResponse(bufio.NewReader(secondConn), &http.Request{Method: http.MethodGet})
if err != nil {
secondConn.Close()
t.Fatal(err)
}
response.Body.Close()
secondConn.Close()
if response.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("expected 503 at concurrency limit, got %d", response.StatusCode)
}
close(release)
response, err = http.ReadResponse(bufio.NewReader(firstConn), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("expected held request to complete, got %d", response.StatusCode)
}
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 {
t.Fatalf("request slot was not released after response: %+v", stats)
}
thirdConn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer thirdConn.Close()
_, _ = io.WriteString(thirdConn, "GET /work HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
response, err = http.ReadResponse(bufio.NewReader(thirdConn), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("request slot was not reusable after overload, got %d", response.StatusCode)
}
}
func TestServerConnectionLimitReleasesAfterRequest(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
@@ -721,6 +846,9 @@ func TestServerConnectionLimitReleasesAfterRequest(t *testing.T) {
t.Fatal("connection over MaxConnections was not rejected")
}
second.Close()
if stats := server.Stats(); stats.RejectedConnections < 1 {
t.Fatalf("expected rejected connection counter, got %+v", stats)
}
close(release)
firstResponse, err := http.ReadResponse(bufio.NewReader(first), &http.Request{Method: http.MethodGet})
@@ -728,6 +856,13 @@ func TestServerConnectionLimitReleasesAfterRequest(t *testing.T) {
t.Fatal(err)
}
firstResponse.Body.Close()
deadline := time.Now().Add(time.Second)
for server.Stats().ActiveConnections != 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if stats := server.Stats(); stats.ActiveConnections != 0 {
t.Fatalf("connection slot was not released after response: %+v", stats)
}
third, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
@@ -745,6 +880,57 @@ func TestServerConnectionLimitReleasesAfterRequest(t *testing.T) {
}
}
func TestServerStatsTrackLifecycle(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
application := omni.NewWithRouter(netrouter.New())
application.GET("/hold", func(ctx *omni.Context) error {
close(started)
<-release
return ctx.OK("done")
})
server, listener, done := startServer(t, application)
defer stopServer(t, server, listener, done)
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
waitForStats := func(check func(native.Stats) bool) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if check(server.Stats()) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("stats condition not reached: %+v", server.Stats())
}
waitForStats(func(stats native.Stats) bool { return stats.ActiveConnections == 1 && stats.AcceptedConnections >= 1 })
if _, err := io.WriteString(conn, "GET /hold HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n"); err != nil {
t.Fatal(err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("hold handler did not start")
}
waitForStats(func(stats native.Stats) bool { return stats.ActiveRequests == 1 })
close(release)
response, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("unexpected hold response: %d", response.StatusCode)
}
waitForStats(func(stats native.Stats) bool { return stats.ActiveRequests == 0 })
waitForStats(func(stats native.Stats) bool { return stats.ActiveConnections == 0 })
}
func TestServerConnectionChurnReusesStateSafely(t *testing.T) {
application := omni.NewWithRouter(netrouter.New())
application.GET("/health", func(ctx *omni.Context) error { return ctx.OK("ok") })

View File

@@ -0,0 +1,101 @@
package native
import (
"bufio"
"context"
"io"
"net"
"net/http"
"sync"
"testing"
"time"
)
type pausedWriteListener struct {
net.Listener
writing chan struct{}
release chan struct{}
}
func (l *pausedWriteListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
return &pausedWriteConn{Conn: conn, writing: l.writing, release: l.release}, nil
}
type pausedWriteConn struct {
net.Conn
writing chan struct{}
release chan struct{}
once sync.Once
}
func (c *pausedWriteConn) Write(data []byte) (int, error) {
c.once.Do(func() { close(c.writing); <-c.release })
return c.Conn.Write(data)
}
func TestShutdownDuringResponseWriteClosesNewlyIdleConnection(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
wrapped := &pausedWriteListener{Listener: listener, writing: make(chan struct{}), release: make(chan struct{})}
var once sync.Once
unblock := func() { once.Do(func() { close(wrapped.release) }) }
defer unblock()
defer listener.Close()
server := New(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "finished") }))
server.ShutdownTimeout = 200 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- server.Serve(ctx, wrapped) }()
client, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer client.Close()
client.SetDeadline(time.Now().Add(2 * time.Second))
if _, err := io.WriteString(client, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); err != nil {
t.Fatal(err)
}
select {
case <-wrapped.writing:
case <-time.After(time.Second):
t.Fatal("response did not reach write")
}
// The response has already chosen its Keep-Alive header. Shutdown must
// still close this connection when its write completes and it becomes idle.
cancel()
deadline := time.Now().Add(time.Second)
for !server.isClosing() {
if time.Now().After(deadline) {
t.Fatal("shutdown did not start")
}
time.Sleep(time.Millisecond)
}
unblock()
response, err := http.ReadResponse(bufio.NewReader(client), &http.Request{Method: "GET"})
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil || string(body) != "finished" {
t.Fatalf("response %q: %v", body, err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("completed request left a connection stuck in drain: %v", err)
}
case <-time.After(time.Second):
t.Fatal("shutdown did not complete")
}
if stats := server.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 {
t.Fatalf("resources retained: %+v", stats)
}
}

View File

@@ -43,8 +43,10 @@ func TestServerSlowHeadersCloseBeforeHandler(t *testing.T) {
func TestServerSlowChunkedBodyClosesBeforeHandler(t *testing.T) {
var handlerCalls atomic.Int32
server := native.New(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
handlerCalls.Add(1)
server := native.New(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
if _, err := io.ReadAll(r.Body); err == nil {
handlerCalls.Add(1)
}
}))
server.ReadTimeout = 40 * time.Millisecond
listener, err := net.Listen("tcp", "127.0.0.1:0")

View File

@@ -10,6 +10,7 @@ import (
"io"
"net"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
@@ -186,3 +187,69 @@ func TestNativeTLSRequestMetadata(t *testing.T) {
t.Fatalf("TLS metadata unavailable to handler: status=%d", response.StatusCode)
}
}
func TestNativeTLSDefaultsToHTTP11ALPN(t *testing.T) {
server := native.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || r.TLS.NegotiatedProtocol != "http/1.1" {
http.Error(w, "missing HTTP/1.1 ALPN", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}))
certificate := testCertificate(t)
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(leaf)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
})
}()
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
t.Errorf("TLS shutdown: %v", err)
}
if err := <-done; err != nil {
t.Errorf("TLS serve: %v", err)
}
}()
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
}}, Timeout: 2 * time.Second}
defer client.CloseIdleConnections()
response, err := client.Get("https://" + listener.Addr().String() + "/")
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("unexpected status: %d", response.StatusCode)
}
}
func TestNativeTLSRejectsUnsupportedALPN(t *testing.T) {
server := native.New(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
certificate := testCertificate(t)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
err = server.ServeTLS(context.Background(), listener, &tls.Config{
Certificates: []tls.Certificate{certificate}, NextProtos: []string{"h2"},
})
if err == nil || !strings.Contains(err.Error(), "unsupported TLS ALPN protocol") {
t.Fatalf("expected unsupported ALPN error, got %v", err)
}
}