Compare commits
50 Commits
v0.6.0-pre
...
f91034477b
| Author | SHA1 | Date | |
|---|---|---|---|
| f91034477b | |||
| b9259e628e | |||
| fe66221a9c | |||
| 467c890363 | |||
| be620d2243 | |||
| 8e18528fc2 | |||
| 59bf04a461 | |||
| b54a303ccd | |||
| 84670f7be1 | |||
| fa2be5e016 | |||
| dfe8a1a9af | |||
| 830cb3a7ad | |||
| d94df4fc54 | |||
| 1a534c7976 | |||
| 2b93545507 | |||
| 15825f0ab8 | |||
| 1b9215114f | |||
| 73a05b8c3c | |||
| 64a9fde204 | |||
| ca0e1d6b92 | |||
| 98f17660c8 | |||
| c8660026ce | |||
| 695bdb7a00 | |||
| 7e9e8aa67c | |||
| de33eda846 | |||
| dd7d3fef0a | |||
| 46c5c9406c | |||
| 1487a96f67 | |||
| 1a20ab53ac | |||
| a072da4a31 | |||
| fcd6f48b53 | |||
| 83633f724e | |||
| a523e6ba3f | |||
| 4a8feaca36 | |||
| 95dc4568f6 | |||
| 2259606f72 | |||
| 35b7470be2 | |||
| 4f97aa0f3d | |||
| 3b4d0a3fad | |||
| a4d843b8f4 | |||
| fadd17a4d4 | |||
| ac46071dfe | |||
| 52d603c31e | |||
| 42021538b2 | |||
| ca707761fc | |||
| 30fdad44c2 | |||
| b7dc36fc99 | |||
| 8e474a24d9 | |||
| ef98d1dd8d | |||
| 5ab906098f |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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 压测。
|
||||
|
||||
170
MODULES.md
Normal file
170
MODULES.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 业务基础模块
|
||||
|
||||
这批能力位于 main,尚未包含在 `v0.6.0-preview` 标签中。统一示例见
|
||||
[examples/modules](examples/modules/README.md)。业务入口继续使用 `omni.New`,中间件依赖
|
||||
`httpx`,不依赖具体服务端网络实现。各模块按需创建、注入 Service,再注册到应用关闭流程。
|
||||
|
||||
## 缓存:cache 与 cache/redis
|
||||
|
||||
```go
|
||||
store, err := cache.NewMemory(cache.MemoryConfig{MaxEntries: 4096, MaxBytes: 32 << 20})
|
||||
if err != nil { return err }
|
||||
application.OnShutdown(store.Close)
|
||||
loader, err := cache.NewLoader(store, cache.LoaderConfig{MaxLoads: 128, Timeout: 5 * time.Second})
|
||||
if err != nil { return err }
|
||||
value, err := loader.GetOrLoad(ctx, "tenant:42:product:7", time.Minute,
|
||||
func(ctx context.Context) ([]byte, error) { return repository.LoadProduct(ctx, "42", "7") })
|
||||
```
|
||||
|
||||
- `Store` 统一 `Get/Set/Delete`,返回字节切片;业务可用 JSON 编解码。
|
||||
- 内存缓存使用 LRU,同时限制条目数和 key/value 总字节数;字节上限不包含 Go 对象开销。
|
||||
读写复制切片,过期时懒清理,没有每个条目一个 goroutine。
|
||||
- TTL 为零表示不过期,负值拒绝;缺失返回 `cache.ErrMiss`。删除不存在的 key 成功。
|
||||
- Loader 合并**同进程、同 key**的并发回源,最多同时运行 `MaxLoads` 个不同 key 的加载。
|
||||
单个调用者取消只停止等待;共享加载有独立超时,加载函数必须响应 context。
|
||||
超限返回 `ErrBusy`,缓存故障直接返回错误,不自动当成 miss 冲击数据库。
|
||||
- 同 key 的调用必须具有相同数据含义、权限范围和 TTL;加载采用首个调用者的 context 值。
|
||||
多租户 key 必须含租户标识。进程间合并、分布式锁、缓存更新与数据库事务一致性由业务另外设计。
|
||||
|
||||
Redis 适配器借用调用方创建的 go-redis 客户端和连接池:
|
||||
|
||||
```go
|
||||
client := goredis.NewClient(&goredis.Options{
|
||||
Addr: "127.0.0.1:6379", PoolSize: 16, MaxRetries: -1,
|
||||
DialTimeout: time.Second, ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second, PoolTimeout: time.Second,
|
||||
ContextTimeoutEnabled: true,
|
||||
})
|
||||
// 启动时先使用有截止时间的 context 执行 client.Ping(ctx)。
|
||||
store, err := rediscache.New(client, "billing:") // cache/redis 的导入别名
|
||||
if err != nil { _ = client.Close(); return err }
|
||||
application.OnShutdown(client.Close)
|
||||
// store 可直接传入 cache.NewLoader。
|
||||
```
|
||||
|
||||
连接失败与 Redis 错误保持原样;只有 Redis nil 映射为 `ErrMiss`。Redis TTL 精度为毫秒,
|
||||
正的亚毫秒 TTL 提升到 1ms。适配器不负责关闭借用的客户端,Redis 服务端容量策略由部署配置。
|
||||
连接池和超时配置参考 [go-redis 官方生产使用说明](https://redis.io/docs/latest/develop/clients/go/produsage/)。
|
||||
|
||||
## 出站 HTTP:httpc
|
||||
|
||||
```go
|
||||
client, err := httpc.New(httpc.Config{
|
||||
Timeout: 3*time.Second, MaxRetries: 2, MaxConnsPerHost: 32,
|
||||
MaxResponseBytes: 1 << 20,
|
||||
})
|
||||
if err != nil { return err }
|
||||
application.OnShutdown(client.Close)
|
||||
err = client.JSON(ctx, "GET", serviceURL, nil, &result)
|
||||
```
|
||||
|
||||
客户端应在服务启动时创建并复用。默认使用独立的 `net/http.Transport` 连接池,每个目标主机
|
||||
有连接上限;该上限不是整个进程的全局连接数上限。也可以注入自有 `http.RoundTripper`,
|
||||
其连接限制和生命周期由调用方负责。这样出站实现同样可以替换。
|
||||
|
||||
总超时覆盖建连、重试、等待和读取响应体。`Do` 返回后调用方必须读取/关闭 `response.Body`;
|
||||
`JSON` 自动关闭,并限制解码前读取的字节数。`Do` 允许流式读取,本身不限制响应体大小。
|
||||
`Close` 拒绝新请求、关闭自有池的空闲连接;在途请求应先排空。
|
||||
|
||||
`MaxRetries` 默认 0,最多 5。封装只对可重放的 GET/HEAD/OPTIONS 重试网络错误及
|
||||
429/502/503/504;POST/PUT/PATCH/DELETE 不做应用层自动重试。标准库 Transport 和借用的
|
||||
RoundTripper 自身的重放规则仍生效,不能据此承诺“只执行一次”。退避带抖动,尊重
|
||||
`Retry-After`;服务器要求等待超过 `MaxBackoff` 时直接返回该响应,不提前重试。
|
||||
重定向交给业务处理,3xx 不会自动跟随。
|
||||
|
||||
默认从 context 传递 `X-Request-ID`,日志只包含方法、主机、状态、尝试次数和收到响应头的耗时,
|
||||
不记录完整 URL、查询参数、凭证和响应体。非 2xx 的 JSON 调用返回只带状态码的 `StatusError`。
|
||||
|
||||
## 请求保护:middleware
|
||||
|
||||
```go
|
||||
limiter, err := middleware.NewRateLimiter(middleware.RateLimitConfig{Rate: 20, Burst: 40})
|
||||
if err != nil { return err }
|
||||
idempotency, err := middleware.NewIdempotency(middleware.IdempotencyConfig{
|
||||
Scope: func(c *httpx.Context) (string, error) {
|
||||
principal, _ := auth.FromContext(c)
|
||||
return principal.UserID, nil // 多租户应用应组合租户和用户 ID
|
||||
},
|
||||
})
|
||||
if err != nil { return err }
|
||||
api := application.Group("/api", middleware.Timeout(3*time.Second),
|
||||
auth.RequireAuth(authenticator), limiter.Middleware())
|
||||
api.POST("/orders", createOrder, idempotency.Middleware())
|
||||
```
|
||||
|
||||
限流为进程内令牌桶,`Rate` 为每秒额度,`Burst` 为突发容量。默认按已解析的可信客户端 IP,
|
||||
可用 `Key` 按已认证身份分桶。额度不足返回 **429 + Retry-After**;key 数超过 `MaxKeys`
|
||||
返回 **503**。满容量时不会踢掉活跃桶让攻击者绕过限流;空闲桶经过完整补充周期后才允许回收。
|
||||
|
||||
Timeout 设置协作式 deadline,不额外启动 Handler goroutine。业务及下游调用必须响应 context;
|
||||
超时后尚未提交响应时返回 **504**,已经提交的响应保持原状态。它不能强制中断任意 Go 代码,
|
||||
也不能替代网络层读写超时。不要在业务代码中异步持有 `httpx.Context`。
|
||||
|
||||
幂等保护适用于小型、非流式写接口:
|
||||
|
||||
- 必须先认证/鉴权,再执行幂等中间件;`Scope` 必须取自服务端验证过的身份。
|
||||
GET/HEAD/OPTIONS 直接通过,写请求要求 `Idempotency-Key`,长度 1..128 个非空格 ASCII 字符。
|
||||
- 同 Scope + key + 方法/URL/Content-Type/body 重放首次响应;内容改变返回 **409**。
|
||||
正在执行或结果不可重放的相同请求也返回 **409**,不并发执行第二次。
|
||||
- 默认最多 32 个处理中请求、256 条记录、16 MiB 保存的响应头/body,单次请求体 1 MiB、
|
||||
响应 body 64 KiB。请求超限 **413**,容量满 **503**,响应超限 **500**。
|
||||
- panic、5xx、超时、响应超限、Set-Cookie、Cache-Control:no-store 或保存容量不足时保留失败记录,
|
||||
阻止在 TTL 内自动重执行业务。业务可能已经提交,调用方应查询实际结果后处理。
|
||||
- 完成记录默认保留 10 分钟;在途记录不会因 TTL 到期而允许重复执行。
|
||||
重放保留原响应 body、原 `requestId` 与原 `X-Request-ID`,增加 `Idempotency-Replayed:true`;
|
||||
本次访问日志仍使用本次请求 ID。
|
||||
- 记录仅在当前进程有效,重启、TTL 到期和多副本切换后均无法保证去重。支付、库存等关键写入
|
||||
应在数据库事务内配合唯一业务键。该实现不提供跨进程 exactly-once,也不支持 SSE/流式/升级连接。
|
||||
|
||||
## 后台执行:task
|
||||
|
||||
```go
|
||||
jobs, err := task.New(task.Config{Workers: 4, QueueSize: 128, TaskTimeout: 30*time.Second})
|
||||
if err != nil { return err }
|
||||
application.OnShutdown(jobs.Close) // 在注册数据库/缓存关闭之后注册,先排空任务
|
||||
future, err := jobs.Submit(serviceContext, "refreshCatalog", refreshCatalog)
|
||||
if err != nil { return err } // 满队列立即返回 ErrQueueFull
|
||||
err = future.Wait(ctx) // 可选;不等待时,任务失败仍通过 OnError/logx 报告
|
||||
err = jobs.Every("refreshCatalog", time.Minute, refreshCatalog)
|
||||
```
|
||||
|
||||
固定 worker 和有界队列控制资源。任务保留提交 context;需要独立于 HTTP 请求执行时使用服务
|
||||
context,或明确使用 `context.WithoutCancel` 保留日志关联值。任务仍受 Runner 的超时/退出控制。
|
||||
任务函数与 `OnError` 回调应及时返回,尤其要响应取消;panic 被转为失败,worker 继续工作。
|
||||
|
||||
`Every` 使用固定间隔,第一次在一个间隔后执行;同一计划上轮尚未完成、排队或队列满时跳过。
|
||||
默认最多 128 个计划,名称不可重复。`Stats` 提供 active/queued/completed/failed/skipped。
|
||||
它是进程内任务执行器,没有任务持久化、失败自动重试、cron 表达式、跨副本选主或崩溃恢复。
|
||||
|
||||
`Shutdown(ctx)` 停止接收和调度,排空已接受任务;截止时间到后取消任务并及时返回。
|
||||
忽略取消的任务不能被 Go 强杀,可能继续运行。`Close` 默认给排空 10 秒。
|
||||
|
||||
## 增加业务模块:go-omni add
|
||||
|
||||
```sh
|
||||
go build -o /tmp/go-omni ./cmd/go-omni
|
||||
/tmp/go-omni add orderItems --dir /path/to/billing
|
||||
```
|
||||
|
||||
生成 `internal/orderItems/{module.go,model,repository,service,handler,README.md}`,包含 name/createdAt
|
||||
示例模型、内存 CRUD、输入校验、错误映射及可注入 Repository。模块名使用 lowerCamelCase。
|
||||
|
||||
在现有启动代码中导入模块后调用 `orderItems.Register(application, repository, authMiddleware)`。
|
||||
脚手架不自动修改现有路由、main、go.mod 或数据库,已有目录不会覆盖。
|
||||
内存 Repository 仅为可运行示例:重启丢数据,列表未分页;正式业务应实现自己的持久化 Repository。
|
||||
|
||||
## 验证入口
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
python3 scripts/verifyModules.py --goproxy https://goproxy.cn,direct
|
||||
```
|
||||
|
||||
Linux runner 顺序运行两个容器:全量 race/vet/build/独立生成项目,随后真实 Redis 适配验证。
|
||||
每次最多一个容器,限制 2 CPU、2 GiB(不额外使用 swap)、256 pids;Redis 测试容器禁外网,
|
||||
只用内部 loopback,不发布端口。Redis 集成二进制为静态非 race,适配器单测在全量 race 中覆盖。
|
||||
结束清理本轮容器,并记录提交、源文件摘要、镜像 ID、日志摘要和原有容器是否仍运行。
|
||||
|
||||
本轮通过结果和原始证据见 [业务模块验收](performance/modulesAcceptance.md)。
|
||||
@@ -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 应按这个含义理解,不能作为服务端延迟或独立内存指标。
|
||||
|
||||
70
README.md
70
README.md
@@ -38,16 +38,25 @@ go-omni/
|
||||
├── health/ # 存活和就绪检查
|
||||
├── middleware/ # 请求 ID 等基础中间件
|
||||
├── logx/ # 业务层结构化日志
|
||||
├── metrics/ # 请求指标与状态统计
|
||||
├── config/ # 配置管理
|
||||
├── validate/ # 参数校验
|
||||
├── auth/ # 认证和权限接口
|
||||
├── database/ # database/sql 生命周期和事务
|
||||
├── cache/ # 有界内存缓存、Redis 适配与合并回源
|
||||
├── httpc/ # 出站 HTTP 连接池、超时和重试
|
||||
├── task/ # 有界后台任务与间隔调度
|
||||
├── transport/nethttp/ # 标准库网络适配器
|
||||
├── transport/native/ # 自研 HTTP/1.1 服务端预览
|
||||
├── transport/http2/ # opt-in HTTP/2 实验适配器
|
||||
├── examples/ # 示例项目
|
||||
└── cmd/ # go-omni 项目脚手架
|
||||
```
|
||||
|
||||
main 分支新增缓存、出站 HTTP、限流/幂等/超时中间件、后台任务和 `go-omni add` 模块脚手架。
|
||||
使用方式、默认容量和进程内能力的适用范围见 [业务基础模块](MODULES.md),
|
||||
可以直接运行 [组合示例](examples/modules/README.md)。这些新增能力尚未包含在上面的历史版本标签中。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```go
|
||||
@@ -123,6 +132,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 +147,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 +176,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 +263,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
|
||||
|
||||
32
ROADMAP.md
32
ROADMAP.md
@@ -42,13 +42,43 @@
|
||||
- 标准库与 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`。
|
||||
|
||||
## 6. 可复用业务基础模块(第一版)
|
||||
|
||||
- `cache`:有界 LRU 内存缓存、TTL、数据复制、同进程合并回源;`cache/redis` 适配 go-redis 客户端,保留借用连接池的生命周期边界。
|
||||
- `httpc`:复用出站连接、每目标主机连接上限、覆盖响应体的总超时、保守重试、requestId 传递、JSON 响应大小限制与安全日志字段。
|
||||
- `middleware`:有容量上限的令牌桶限流;按已认证身份隔离的幂等保护,覆盖冲突、处理中记录、失败记录、容量与请求/响应大小上限;协作式请求 deadline。
|
||||
- `task`:固定 worker、有界队列、任务超时、panic 隔离、间隔调度、不重叠执行、停止接收与排空/取消。
|
||||
- `go-omni add`:向已有项目生成 Handler / Service / Repository / Model 和模块注册入口;目标存在时拒绝覆盖,默认内存 CRUD 可注入持久化 Repository。
|
||||
- `examples/modules` 将以上运行时能力串联,使用真实 TLS 在 nethttp、native、实验 HTTP/2 下验证缓存、出站 requestId、认证、幂等、后台任务与退出。
|
||||
- 使用文档:[业务基础模块](MODULES.md)。验证入口:`GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...` 和 `python3 scripts/verifyModules.py`;Linux runner 顺序运行全量检查及独立 Redis 容器,硬限制 2 CPU / 2 GiB / 256 pids。
|
||||
- 最终代码已通过 Linux amd64 全仓库 race/vet/build、独立生成项目 CRUD、真实 Redis 7.4.11 与三种网络层 TLS 组合测试;测试容器清理完成、原有 7 个业务容器仍运行。提交、源码摘要与完整输出见 [验收记录](performance/modulesAcceptance.md)。
|
||||
- 当前幂等、限流、任务调度和合并回源均为单进程能力;Redis 仅提供缓存存储,不代表已实现分布式去重、任务持久化或跨副本选主。eTax 指定业务模块接入依用户要求暂缓。
|
||||
|
||||
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。
|
||||
|
||||
68
app/app.go
68
app/app.go
@@ -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:
|
||||
|
||||
@@ -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
79
app/transport.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||
)
|
||||
|
||||
// ServeContext serves a caller-created listener using the configured transport
|
||||
// and TLS. App takes ownership of the listener, including on startup failure,
|
||||
// and runs resource cleanup after the server has stopped.
|
||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) error {
|
||||
if listener == nil {
|
||||
return errors.Join(errors.New("go-omni: nil listener"), a.Close())
|
||||
}
|
||||
defer listener.Close()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
_ = listener.Close()
|
||||
return a.Close()
|
||||
}
|
||||
serve, err := a.prepareTransport()
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
return errors.Join(err, a.Close())
|
||||
}
|
||||
return errors.Join(serve(ctx, listener), a.Close())
|
||||
}
|
||||
|
||||
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
|
||||
if err := a.config.ValidateTransport(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shutdownTimeout := a.config.ShutdownTimeout
|
||||
if shutdownTimeout <= 0 {
|
||||
shutdownTimeout = 10 * time.Second
|
||||
}
|
||||
var tlsConfig *tls.Config
|
||||
if strings.TrimSpace(a.config.TLS.CertFile) != "" {
|
||||
certificate, err := tls.LoadX509KeyPair(a.config.TLS.CertFile, a.config.TLS.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go-omni: load TLS certificate: %w", err)
|
||||
}
|
||||
tlsConfig = &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
switch a.config.Transport {
|
||||
case config.TransportNative:
|
||||
server := native.New(a)
|
||||
server.ShutdownTimeout = shutdownTimeout
|
||||
if tlsConfig != nil {
|
||||
return func(ctx context.Context, listener net.Listener) error {
|
||||
return server.ServeTLS(ctx, listener, tlsConfig)
|
||||
}, nil
|
||||
}
|
||||
return server.Serve, nil
|
||||
case config.TransportHTTP2:
|
||||
server := h2transport.New(a)
|
||||
server.ShutdownTimeout = shutdownTimeout
|
||||
return func(ctx context.Context, listener net.Listener) error {
|
||||
return server.ServeTLS(ctx, listener, tlsConfig)
|
||||
}, nil
|
||||
default:
|
||||
server := a.NewServer("")
|
||||
server.TLSConfig = tlsConfig
|
||||
return func(ctx context.Context, listener net.Listener) error {
|
||||
return a.serveContext(ctx, server, listener)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
322
app/transport_test.go
Normal file
322
app/transport_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||
)
|
||||
|
||||
func TestConfiguredTransportLifecycle(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
for _, timeout := range []bool{false, true} {
|
||||
name := transport + "/drain"
|
||||
if timeout {
|
||||
name = transport + "/timeout"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
limit := 3 * time.Second
|
||||
if timeout {
|
||||
limit = 80 * time.Millisecond
|
||||
}
|
||||
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(limit))
|
||||
started, release, canceled := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var once sync.Once
|
||||
unblock := func() { once.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
var cleaned atomic.Int32
|
||||
application.OnShutdown(func() error { cleaned.Add(1); return nil })
|
||||
application.GET("/slow", func(ctx *httpx.Context) error {
|
||||
close(started)
|
||||
select {
|
||||
case <-ctx.Context().Done():
|
||||
close(canceled)
|
||||
return ctx.Context().Err()
|
||||
case <-release:
|
||||
return ctx.OK("finished")
|
||||
}
|
||||
})
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrapped := &closingListener{Listener: listener, closed: make(chan struct{})}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
defer listener.Close()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- application.ServeContext(ctx, wrapped) }()
|
||||
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
requestDone := make(chan error, 1)
|
||||
go func() {
|
||||
response, err := client.Get("https://" + listener.Addr().String() + "/slow")
|
||||
if err == nil {
|
||||
defer response.Body.Close()
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
err = readErr
|
||||
if response.StatusCode != 200 || !strings.Contains(string(body), "finished") {
|
||||
err = errors.New("drained response was lost")
|
||||
}
|
||||
}
|
||||
requestDone <- err
|
||||
}()
|
||||
await(t, started)
|
||||
began := time.Now()
|
||||
cancel()
|
||||
await(t, wrapped.closed)
|
||||
if !timeout {
|
||||
select {
|
||||
case <-canceled:
|
||||
t.Fatal("active request canceled before drain")
|
||||
default:
|
||||
}
|
||||
if cleaned.Load() != 0 {
|
||||
t.Fatal("cleanup preceded active request")
|
||||
}
|
||||
unblock()
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if timeout {
|
||||
if !errors.Is(err, context.DeadlineExceeded) || time.Since(began) < limit {
|
||||
t.Fatalf("timeout ignored: %v after %v", err, time.Since(began))
|
||||
}
|
||||
await(t, canceled)
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("server failed to stop")
|
||||
}
|
||||
select {
|
||||
case err := <-requestDone:
|
||||
if !timeout && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("client failed to finish")
|
||||
}
|
||||
_ = application.Close()
|
||||
if cleaned.Load() != 1 {
|
||||
t.Fatalf("cleanup count %d", cleaned.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredStartupFailureClosesResources(t *testing.T) {
|
||||
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer occupied.Close()
|
||||
for _, test := range []struct {
|
||||
name, errorText string
|
||||
options []config.Option
|
||||
}{
|
||||
{"unknown", "transport", []config.Option{config.WithTransport("unknown")}},
|
||||
{"http2WithoutTLS", "tls", []config.Option{config.WithTransport("http2")}},
|
||||
{"partialTLS", "tls", []config.Option{config.WithTLS("missing.crt", "")}},
|
||||
{"nethttpCertificate", "load TLS certificate", []config.Option{config.WithTLS("missing.crt", "missing.key")}},
|
||||
{"nativeCertificate", "load TLS certificate", []config.Option{config.WithTransport("native"), config.WithTLS("missing.crt", "missing.key")}},
|
||||
{"http2Certificate", "load TLS certificate", []config.Option{config.WithTransport("http2"), config.WithTLS("missing.crt", "missing.key")}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
application := New(test.options...)
|
||||
cleanupError := errors.New("cleanup failure")
|
||||
count := 0
|
||||
application.OnShutdown(func() error { count++; return cleanupError })
|
||||
// The occupied address must not mask configuration/certificate errors.
|
||||
err := application.RunContext(context.Background(), occupied.Addr().String())
|
||||
if err == nil || !strings.Contains(err.Error(), test.errorText) || !errors.Is(err, cleanupError) {
|
||||
t.Fatalf("startup: %v", err)
|
||||
}
|
||||
_ = application.Close()
|
||||
if count != 1 {
|
||||
t.Fatalf("cleanup count %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, canceled := range []bool{false, true} {
|
||||
application := New(config.WithTransport("unknown"))
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if canceled {
|
||||
cancel()
|
||||
}
|
||||
err = application.ServeContext(ctx, listener)
|
||||
cancel()
|
||||
if canceled && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !canceled && err == nil {
|
||||
t.Fatal("invalid transport accepted")
|
||||
}
|
||||
if _, err := listener.Accept(); !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("listener leaked: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunContextConfiguredTLS(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
reserved, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := reserved.Addr().String()
|
||||
reserved.Close()
|
||||
settings := config.Default()
|
||||
settings.Address, settings.Transport = address, transport
|
||||
settings.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
|
||||
application := New(config.WithConfig(settings))
|
||||
application.GET("/health", func(ctx *httpx.Context) error { return ctx.OK("ok") })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- application.RunContext(ctx) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("RunContext failed to stop")
|
||||
}
|
||||
}()
|
||||
client := &http.Client{Timeout: time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
response, err := client.Get("https://" + address + "/health")
|
||||
if err == nil {
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != 200 || !strings.Contains(string(body), `"data":"ok"`) {
|
||||
t.Fatalf("response %s: %v", body, readErr)
|
||||
}
|
||||
want := "h2"
|
||||
if transport == config.TransportNative {
|
||||
want = "http/1.1"
|
||||
}
|
||||
if response.TLS.NegotiatedProtocol != want || len(response.TLS.VerifiedChains) == 0 {
|
||||
t.Fatalf("TLS negotiation: %+v", response.TLS)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type runnerFunc func(context.Context, http.Handler, string) error
|
||||
|
||||
func (f runnerFunc) Run(ctx context.Context, handler http.Handler, address string) error {
|
||||
return f(ctx, handler, address)
|
||||
}
|
||||
|
||||
func TestRunWithRemainsIndependentOfBuiltInTransport(t *testing.T) {
|
||||
for _, canceled := range []bool{false, true} {
|
||||
application := New(config.WithTransport("custom"), config.WithTLS("missing", "missing"))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if canceled {
|
||||
cancel()
|
||||
}
|
||||
calls, cleanups := 0, 0
|
||||
application.OnShutdown(func() error { cleanups++; return nil })
|
||||
err := application.RunWith(ctx, runnerFunc(func(_ context.Context, handler http.Handler, address string) error {
|
||||
calls++
|
||||
if handler != application || address != "127.0.0.1:1234" {
|
||||
t.Fatal("custom runner arguments changed")
|
||||
}
|
||||
return nil
|
||||
}), "127.0.0.1:1234")
|
||||
cancel()
|
||||
wantCalls := 1
|
||||
if canceled {
|
||||
wantCalls = 0
|
||||
}
|
||||
if err != nil || calls != wantCalls || cleanups != 1 {
|
||||
t.Fatalf("RunWith: %v calls=%d cleanups=%d", err, calls, cleanups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond))
|
||||
started := make(chan context.Context, 1)
|
||||
release, finished := make(chan struct{}), make(chan struct{})
|
||||
application.GET("/blocked", func(ctx *httpx.Context) error {
|
||||
defer close(finished)
|
||||
started <- ctx.Context()
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- application.ServeContext(ctx, listener) }()
|
||||
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
requestDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(requestDone)
|
||||
response, err := client.Get("https://" + listener.Addr().String() + "/blocked")
|
||||
if err == nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
}()
|
||||
var requestContext context.Context
|
||||
select {
|
||||
case requestContext = <-started:
|
||||
case <-time.After(3 * time.Second):
|
||||
close(release)
|
||||
t.Fatal("handler did not start")
|
||||
}
|
||||
defer func() { close(release); await(t, finished) }()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("shutdown: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("handler defeated shutdown deadline")
|
||||
}
|
||||
// net/http's HTTP/2 streams observe the closed socket asynchronously.
|
||||
await(t, requestContext.Done())
|
||||
await(t, requestDone)
|
||||
})
|
||||
}
|
||||
}
|
||||
31
app_test.go
31
app_test.go
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
135
cache/cache.go
vendored
Normal file
135
cache/cache.go
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
// Package cache provides replaceable byte-oriented caches and bounded local
|
||||
// request coalescing. Storage errors are never silently treated as misses.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMiss = errors.New("cache: key not found")
|
||||
ErrClosed = errors.New("cache: closed")
|
||||
ErrTooLarge = errors.New("cache: entry exceeds capacity")
|
||||
ErrBusy = errors.New("cache: too many concurrent loads")
|
||||
)
|
||||
|
||||
// Store implementations copy values and accept ttl=0 for no expiration.
|
||||
// Negative TTLs are invalid. Delete is successful for missing keys.
|
||||
type Store interface {
|
||||
Get(context.Context, string) ([]byte, error)
|
||||
Set(context.Context, string, []byte, time.Duration) error
|
||||
Delete(context.Context, ...string) error
|
||||
}
|
||||
|
||||
func Check(ctx context.Context, key string, ttl time.Duration) error {
|
||||
if ctx == nil {
|
||||
return errors.New("cache: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if key == "" || len(key) > 1024 {
|
||||
return errors.New("cache: key must contain 1..1024 bytes")
|
||||
}
|
||||
if ttl < 0 {
|
||||
return errors.New("cache: negative TTL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type LoaderConfig struct {
|
||||
MaxLoads int `json:"maxLoads"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
type flight struct {
|
||||
done chan struct{}
|
||||
value []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// Loader coalesces concurrent misses for the same key within this process.
|
||||
// Caller cancellation stops waiting without canceling other callers' load.
|
||||
// Load functions must observe their context, which has a bounded deadline.
|
||||
type Loader struct {
|
||||
store Store
|
||||
config LoaderConfig
|
||||
mu sync.Mutex
|
||||
flights map[string]*flight
|
||||
}
|
||||
|
||||
func NewLoader(store Store, cfg LoaderConfig) (*Loader, error) {
|
||||
if store == nil || cfg.MaxLoads < 0 || cfg.Timeout < 0 {
|
||||
return nil, errors.New("cache: invalid loader configuration")
|
||||
}
|
||||
if cfg.MaxLoads == 0 {
|
||||
cfg.MaxLoads = 128
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
return &Loader{store: store, config: cfg, flights: make(map[string]*flight)}, nil
|
||||
}
|
||||
|
||||
func (l *Loader) GetOrLoad(ctx context.Context, key string, ttl time.Duration, load func(context.Context) ([]byte, error)) ([]byte, error) {
|
||||
if err := Check(ctx, key, ttl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if load == nil {
|
||||
return nil, errors.New("cache: nil loader")
|
||||
}
|
||||
value, err := l.store.Get(ctx, key)
|
||||
if !errors.Is(err, ErrMiss) {
|
||||
return value, err
|
||||
}
|
||||
l.mu.Lock()
|
||||
f := l.flights[key]
|
||||
if f == nil {
|
||||
if len(l.flights) >= l.config.MaxLoads {
|
||||
l.mu.Unlock()
|
||||
return nil, ErrBusy
|
||||
}
|
||||
f = &flight{done: make(chan struct{})}
|
||||
l.flights[key] = f
|
||||
go l.run(context.WithoutCancel(ctx), key, ttl, load, f)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-f.done:
|
||||
return append([]byte(nil), f.value...), f.err
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loader) run(parent context.Context, key string, ttl time.Duration, load func(context.Context) ([]byte, error), f *flight) {
|
||||
ctx, cancel := context.WithTimeout(parent, l.config.Timeout)
|
||||
defer cancel()
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
f.err = fmt.Errorf("cache: loader panic: %v", value)
|
||||
}
|
||||
l.mu.Lock()
|
||||
delete(l.flights, key)
|
||||
close(f.done)
|
||||
l.mu.Unlock()
|
||||
}()
|
||||
// Another request may have populated the key between the first Get and
|
||||
// flight acquisition. Recheck inside the coalesced load.
|
||||
f.value, f.err = l.store.Get(ctx, key)
|
||||
if !errors.Is(f.err, ErrMiss) {
|
||||
return
|
||||
}
|
||||
f.value, f.err = load(ctx)
|
||||
if f.err == nil {
|
||||
f.err = ctx.Err()
|
||||
}
|
||||
if f.err == nil {
|
||||
f.err = l.store.Set(ctx, key, f.value, ttl)
|
||||
}
|
||||
f.value = append([]byte(nil), f.value...)
|
||||
}
|
||||
118
cache/cache_test.go
vendored
Normal file
118
cache/cache_test.go
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryOwnershipCapacityExpiryAndClose(t *testing.T) {
|
||||
m, err := NewMemory(MemoryConfig{MaxEntries: 2, MaxBytes: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
m.now = func() time.Time { return now }
|
||||
ctx := context.Background()
|
||||
value := []byte("aa")
|
||||
if err := m.Set(ctx, "a", value, time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value[0] = 'x'
|
||||
got, _ := m.Get(ctx, "a")
|
||||
if string(got) != "aa" {
|
||||
t.Fatal("retained caller memory")
|
||||
}
|
||||
got[0] = 'z'
|
||||
got, _ = m.Get(ctx, "a")
|
||||
if string(got) != "aa" {
|
||||
t.Fatal("returned mutable cache memory")
|
||||
}
|
||||
m.Set(ctx, "b", []byte("bb"), 0)
|
||||
m.Get(ctx, "a")
|
||||
m.Set(ctx, "c", []byte("cc"), 0)
|
||||
if _, err := m.Get(ctx, "b"); !errors.Is(err, ErrMiss) {
|
||||
t.Fatal("LRU eviction failed")
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
if _, err := m.Get(ctx, "a"); !errors.Is(err, ErrMiss) {
|
||||
t.Fatal("expiration boundary ignored")
|
||||
}
|
||||
if err := m.Set(ctx, "large", []byte("123456"), 0); !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Get(ctx, "c"); err != nil {
|
||||
t.Fatal("oversized write evicted valid data")
|
||||
}
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
if err := m.Delete(canceled, "c"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Close()
|
||||
m.Close()
|
||||
if _, err := m.Get(ctx, "c"); !errors.Is(err, ErrClosed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoaderCoalescesAndSeparatesCallerCancellation(t *testing.T) {
|
||||
m, _ := NewMemory(MemoryConfig{})
|
||||
loader, _ := NewLoader(m, LoaderConfig{MaxLoads: 1, Timeout: time.Second})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
load := func(ctx context.Context) ([]byte, error) {
|
||||
calls.Add(1)
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
return []byte("value"), nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
first := make(chan error, 1)
|
||||
go func() { _, err := loader.GetOrLoad(ctx, "key", time.Minute, load); first <- err }()
|
||||
<-started
|
||||
second := make(chan error, 1)
|
||||
go func() {
|
||||
v, err := loader.GetOrLoad(context.Background(), "key", time.Minute, load)
|
||||
if err == nil && string(v) != "value" {
|
||||
err = errors.New("wrong value")
|
||||
}
|
||||
second <- err
|
||||
}()
|
||||
if _, err := loader.GetOrLoad(context.Background(), "other", 0, load); !errors.Is(err, ErrBusy) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
if err := <-first; !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(release)
|
||||
if err := <-second; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("loads=%d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoaderPanicDeadlineAndStoreFailure(t *testing.T) {
|
||||
m, _ := NewMemory(MemoryConfig{})
|
||||
l, _ := NewLoader(m, LoaderConfig{Timeout: 20 * time.Millisecond})
|
||||
if _, err := l.GetOrLoad(context.Background(), "panic", 0, func(context.Context) ([]byte, error) { panic("failure") }); err == nil {
|
||||
t.Fatal("panic escaped")
|
||||
}
|
||||
if _, err := l.GetOrLoad(context.Background(), "panic", 0, func(ctx context.Context) ([]byte, error) { <-ctx.Done(); return nil, ctx.Err() }); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Close()
|
||||
if _, err := l.GetOrLoad(context.Background(), "closed", 0, func(context.Context) ([]byte, error) { t.Fatal("store outage triggered load"); return nil, nil }); !errors.Is(err, ErrClosed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
135
cache/memory.go
vendored
Normal file
135
cache/memory.go
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MemoryConfig struct {
|
||||
MaxEntries int `json:"maxEntries"`
|
||||
MaxBytes int `json:"maxBytes"` // Sum of key and value bytes, excluding Go object overhead.
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
key string
|
||||
value []byte
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// Memory is an LRU cache with lazy expiration and no background goroutines.
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
config MemoryConfig
|
||||
items map[string]*list.Element
|
||||
lru *list.List
|
||||
bytes int
|
||||
closed bool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewMemory(cfg MemoryConfig) (*Memory, error) {
|
||||
if cfg.MaxEntries < 0 || cfg.MaxBytes < 0 {
|
||||
return nil, errors.New("cache: negative capacity")
|
||||
}
|
||||
if cfg.MaxEntries == 0 {
|
||||
cfg.MaxEntries = 4096
|
||||
}
|
||||
if cfg.MaxBytes == 0 {
|
||||
cfg.MaxBytes = 32 << 20
|
||||
}
|
||||
return &Memory{config: cfg, items: make(map[string]*list.Element), lru: list.New(), now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (m *Memory) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
if err := Check(ctx, key, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.closed {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
e := m.items[key]
|
||||
if e == nil {
|
||||
return nil, ErrMiss
|
||||
}
|
||||
item := e.Value.(*entry)
|
||||
if !item.expires.IsZero() && !m.now().Before(item.expires) {
|
||||
m.remove(e)
|
||||
return nil, ErrMiss
|
||||
}
|
||||
m.lru.MoveToFront(e)
|
||||
return append([]byte(nil), item.value...), nil
|
||||
}
|
||||
|
||||
func (m *Memory) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
|
||||
if err := Check(ctx, key, ttl); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(value) > m.config.MaxBytes-len(key) {
|
||||
return ErrTooLarge
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.closed {
|
||||
return ErrClosed
|
||||
}
|
||||
if e := m.items[key]; e != nil {
|
||||
m.remove(e)
|
||||
}
|
||||
for len(m.items) >= m.config.MaxEntries || m.bytes+len(key)+len(value) > m.config.MaxBytes {
|
||||
m.remove(m.lru.Back())
|
||||
}
|
||||
item := &entry{key: key, value: append([]byte(nil), value...)}
|
||||
if ttl > 0 {
|
||||
item.expires = m.now().Add(ttl)
|
||||
}
|
||||
m.items[key] = m.lru.PushFront(item)
|
||||
m.bytes += len(key) + len(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) Delete(ctx context.Context, keys ...string) error {
|
||||
if ctx == nil {
|
||||
return errors.New("cache: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
if err := Check(ctx, key, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.closed {
|
||||
return ErrClosed
|
||||
}
|
||||
for _, key := range keys {
|
||||
if e := m.items[key]; e != nil {
|
||||
m.remove(e)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) remove(e *list.Element) {
|
||||
item := e.Value.(*entry)
|
||||
m.bytes -= len(item.key) + len(item.value)
|
||||
delete(m.items, item.key)
|
||||
m.lru.Remove(e)
|
||||
}
|
||||
|
||||
func (m *Memory) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.closed = true
|
||||
clear(m.items)
|
||||
m.lru.Init()
|
||||
m.bytes = 0
|
||||
return nil
|
||||
}
|
||||
68
cache/redis/redis.go
vendored
Normal file
68
cache/redis/redis.go
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package redis adapts go-redis's pooled client to cache.Store.
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cache"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Store borrows the client; its owner must close the client after users stop.
|
||||
type Store struct {
|
||||
client goredis.Cmdable
|
||||
prefix string
|
||||
}
|
||||
|
||||
func New(client goredis.Cmdable, prefix string) (*Store, error) {
|
||||
if client == nil || len(prefix) > 1024 || strings.ContainsAny(prefix, "\r\n") {
|
||||
return nil, errors.New("cache/redis: invalid client or prefix")
|
||||
}
|
||||
return &Store{client: client, prefix: prefix}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
if err := cache.Check(ctx, key, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := s.client.Get(ctx, s.prefix+key).Bytes()
|
||||
if errors.Is(err, goredis.Nil) {
|
||||
return nil, cache.ErrMiss
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *Store) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
|
||||
if err := cache.Check(ctx, key, ttl); err != nil {
|
||||
return err
|
||||
}
|
||||
// Redis expiration precision is milliseconds; round up, never truncate a
|
||||
// positive duration to an unbounded lifetime.
|
||||
if ttl > 0 && ttl < time.Millisecond {
|
||||
ttl = time.Millisecond
|
||||
}
|
||||
return s.client.Set(ctx, s.prefix+key, value, ttl).Err()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, keys ...string) error {
|
||||
if ctx == nil {
|
||||
return errors.New("cache/redis: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
names := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
if err := cache.Check(ctx, key, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
names[i] = s.prefix + key
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.client.Del(ctx, names...).Err()
|
||||
}
|
||||
125
cache/redis/redis_test.go
vendored
Normal file
125
cache/redis/redis_test.go
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cache"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
goredis.Cmdable
|
||||
key string
|
||||
ttl time.Duration
|
||||
failure error
|
||||
}
|
||||
|
||||
func (f *fakeClient) Get(ctx context.Context, key string) *goredis.StringCmd {
|
||||
f.key = key
|
||||
return goredis.NewStringResult("", f.failure)
|
||||
}
|
||||
func (f *fakeClient) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) *goredis.StatusCmd {
|
||||
f.key, f.ttl = key, ttl
|
||||
return goredis.NewStatusResult("OK", f.failure)
|
||||
}
|
||||
func (f *fakeClient) Del(ctx context.Context, keys ...string) *goredis.IntCmd {
|
||||
f.key = keys[0]
|
||||
return goredis.NewIntResult(1, f.failure)
|
||||
}
|
||||
|
||||
func TestAdapterPreservesErrorsAndExpiration(t *testing.T) {
|
||||
f := &fakeClient{failure: goredis.Nil}
|
||||
store, _ := New(f, "app:")
|
||||
if _, err := store.Get(context.Background(), "key"); !errors.Is(err, cache.ErrMiss) || f.key != "app:key" {
|
||||
t.Fatal(err, f.key)
|
||||
}
|
||||
f.failure = errors.New("unavailable")
|
||||
if _, err := store.Get(context.Background(), "key"); !errors.Is(err, f.failure) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.failure = nil
|
||||
if err := store.Set(context.Background(), "key", []byte("value"), time.Nanosecond); err != nil || f.ttl != time.Millisecond {
|
||||
t.Fatal(err, f.ttl)
|
||||
}
|
||||
if err := store.Set(context.Background(), "key", nil, -time.Second); err == nil {
|
||||
t.Fatal("negative TTL accepted")
|
||||
}
|
||||
if err := store.Delete(context.Background(), "key"); err != nil || f.key != "app:key" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisIntegration(t *testing.T) {
|
||||
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
|
||||
if address == "" {
|
||||
t.Skip("set GO_OMNI_REDIS_TEST_ADDR to an isolated test Redis")
|
||||
}
|
||||
client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 2, MaxRetries: -1, ContextTimeoutEnabled: true, DialTimeout: time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second})
|
||||
defer client.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prefix := fmt.Sprintf("goOmniTest:%d:", time.Now().UnixNano())
|
||||
store, _ := New(client, prefix)
|
||||
defer store.Delete(context.Background(), "key", "forever", "list", "canceled")
|
||||
if err := store.Set(ctx, "key", []byte{0, 1, 255}, 80*time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := store.Get(ctx, "key")
|
||||
if err != nil || len(v) != 3 || v[2] != 255 {
|
||||
t.Fatal(v, err)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
_, err := store.Get(ctx, "key")
|
||||
if errors.Is(err, cache.ErrMiss) {
|
||||
break
|
||||
}
|
||||
if err != nil || time.Now().After(deadline) {
|
||||
t.Fatal("TTL failed", err)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if err := store.Set(ctx, "forever", []byte("ok"), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ttl := client.PTTL(ctx, prefix+"forever").Val(); ttl != -1 {
|
||||
t.Fatalf("unexpected persistent TTL %v", ttl)
|
||||
}
|
||||
if err := store.Delete(ctx, "forever"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "forever"); !errors.Is(err, cache.ErrMiss) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.RPush(ctx, prefix+"list", "value").Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "list"); err == nil || errors.Is(err, cache.ErrMiss) {
|
||||
t.Fatal("Redis error treated as miss", err)
|
||||
}
|
||||
canceled, stop := context.WithCancel(ctx)
|
||||
stop()
|
||||
if err := store.Set(canceled, "canceled", []byte("value"), 0); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "canceled"); !errors.Is(err, cache.ErrMiss) {
|
||||
t.Fatal("canceled write reached Redis", err)
|
||||
}
|
||||
if err := store.Delete(ctx, "list"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "key"); err == nil || errors.Is(err, cache.ErrMiss) {
|
||||
t.Fatal("closed client treated as miss", err)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
# go-omni CLI
|
||||
|
||||
main 分支还提供 `go-omni add orderItems --dir /path/to/project`,向已有 GoOmni 项目增加
|
||||
Handler / Service / Repository / Model 与模块注册入口。模块名为 lowerCamelCase,
|
||||
目标目录存在时拒绝覆盖,已有启动代码保持原样。生成模块带可替换的内存 CRUD Repository,
|
||||
导入后使用 `orderItems.Register(application, repository, authMiddleware)` 注册。
|
||||
该命令尚未包含在下方历史标签中;当前源码可用 `go build -o /tmp/go-omni ./cmd/go-omni` 构建。
|
||||
边界和验证方式见 [业务基础模块](../../MODULES.md)。
|
||||
|
||||
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -17,6 +17,11 @@ func main() {
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "add":
|
||||
if err := runAdd(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni add:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "new":
|
||||
if err := runNew(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni new:", err)
|
||||
@@ -104,7 +109,36 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Usage:")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
|
||||
fmt.Fprintln(os.Stderr, " go-omni add <name> [--dir <project>] Generate an isolated business module")
|
||||
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")
|
||||
fmt.Fprintln(os.Stderr, " --framework-dir <directory> Use a local framework checkout")
|
||||
fmt.Fprintln(os.Stderr, " --database <memory|mysql> Select generated storage (default memory)")
|
||||
}
|
||||
|
||||
func runAdd(args []string) error {
|
||||
directory, name := ".", ""
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "--dir":
|
||||
if index+1 >= len(args) {
|
||||
return errors.New("--dir requires a project directory")
|
||||
}
|
||||
index++
|
||||
directory = args[index]
|
||||
case strings.HasPrefix(arg, "--dir="):
|
||||
directory = strings.TrimPrefix(arg, "--dir=")
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return fmt.Errorf("unknown option %q", arg)
|
||||
case name == "":
|
||||
name = arg
|
||||
default:
|
||||
return fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
}
|
||||
if err := scaffold.AddModule(directory, name); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created internal/%s; see its README.md for route registration\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -32,6 +32,30 @@ func TestNewRejectsEmptyFrameworkVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddModuleCLI(t *testing.T) {
|
||||
for _, equals := range []bool{false, true} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
if err := runNew([]string{"--module", "example.com/billing", project}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := []string{"orders", "--dir", project}
|
||||
if equals {
|
||||
args = []string{"--dir=" + project, "orders"}
|
||||
}
|
||||
if err := runAdd(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(project, "internal/orders/module.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{nil, {"orders", "--dir"}, {"orders", "--dir="}, {"orders", "extra"}, {"orders", "--unknown"}} {
|
||||
if err := runAdd(args); err == nil {
|
||||
t.Fatalf("accepted invalid arguments %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMySQLDatabaseOptions(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--database", "mysql"}, {"--database=mysql"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
|
||||
109
cmd/go-omni/scaffold/addModule.go
Normal file
109
cmd/go-omni/scaffold/addModule.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package scaffold
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
)
|
||||
|
||||
var moduleName = regexp.MustCompile(`^[a-z][a-zA-Z0-9]{0,47}$`)
|
||||
|
||||
// AddModule creates a complete, injectable memory-backed CRUD module. It never
|
||||
// edits existing routes, go.mod, migrations or files in another module.
|
||||
func AddModule(directory, name string) (err error) {
|
||||
if strings.TrimSpace(directory) == "" || !moduleName.MatchString(name) || token.Lookup(name).IsKeyword() || name == "main" || name == "init" || name == "internal" {
|
||||
return errors.New("module name must be a non-keyword lowerCamelCase Go identifier (1..48 characters)")
|
||||
}
|
||||
root, err := filepath.Abs(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read project go.mod: %w", err)
|
||||
}
|
||||
manifest, err := modfile.Parse("go.mod", data, nil)
|
||||
if err != nil || manifest.Module == nil {
|
||||
return errors.New("project go.mod must declare a valid module")
|
||||
}
|
||||
found := false
|
||||
for _, dependency := range manifest.Require {
|
||||
if dependency.Mod.Path == frameworkModule {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("project must already require go-omni; create it with go-omni new")
|
||||
}
|
||||
module := manifest.Module.Mod.Path
|
||||
if strings.ContainsAny(module, "\"\\\r\n") {
|
||||
return errors.New("invalid project module path")
|
||||
}
|
||||
paths := []string{"module.go", "model/model.go", "repository/repository.go", "service/service.go", "handler/handler.go", "README.md"}
|
||||
rendered := make(map[string][]byte, len(paths))
|
||||
for _, path := range paths {
|
||||
source, readErr := templateFiles.ReadFile("moduleTemplates/" + strings.ReplaceAll(path, "/", "-") + ".tmpl")
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
tmpl, parseErr := template.New(path).Parse(string(source))
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := tmpl.Execute(&output, struct{ Module, Name string }{module, name}); err != nil {
|
||||
return err
|
||||
}
|
||||
content := output.Bytes()
|
||||
if strings.HasSuffix(path, ".go") {
|
||||
content, err = format.Source(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rendered[path] = content
|
||||
}
|
||||
parent := filepath.Join(root, "internal")
|
||||
if info, statErr := os.Lstat(parent); statErr == nil {
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return errors.New("internal must be a real directory, not a symlink")
|
||||
}
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
return statErr
|
||||
} else if err := os.Mkdir(parent, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(parent, name)
|
||||
// Exclusive creation protects existing files, including empty directories.
|
||||
if err := os.Mkdir(target, 0755); err != nil {
|
||||
return fmt.Errorf("create module (existing modules are never overwritten): %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(target)
|
||||
}
|
||||
}()
|
||||
for _, path := range paths {
|
||||
file := filepath.Join(target, filepath.FromSlash(path))
|
||||
if err = os.MkdirAll(filepath.Dir(file), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.WriteFile(file, rendered[path], 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
137
cmd/go-omni/scaffold/addModule_test.go
Normal file
137
cmd/go-omni/scaffold/addModule_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package scaffold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func moduleProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "billing")
|
||||
if err := Generate(Options{Dir: root, Module: "example.com/billing", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestAddModulePreservesProjectAndRejectsOverwrite(t *testing.T) {
|
||||
root := moduleProject(t)
|
||||
manifest, _ := os.ReadFile(filepath.Join(root, "go.mod"))
|
||||
main, _ := os.ReadFile(filepath.Join(root, "cmd/server/main.go"))
|
||||
if err := AddModule(root, "orderItems"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"module.go", "model/model.go", "handler/handler.go", "service/service.go", "repository/repository.go", "README.md"} {
|
||||
if _, err := os.Stat(filepath.Join(root, "internal/orderItems", path)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
marker := filepath.Join(root, "internal/orderItems/keep.txt")
|
||||
if err := os.WriteFile(marker, []byte("keep"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AddModule(root, "orderItems"); err == nil {
|
||||
t.Fatal("overwrote existing module")
|
||||
}
|
||||
if got, _ := os.ReadFile(marker); string(got) != "keep" {
|
||||
t.Fatal("existing file changed")
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(root, "go.mod")); string(got) != string(manifest) {
|
||||
t.Fatal("go.mod changed")
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(root, "cmd/server/main.go")); string(got) != string(main) {
|
||||
t.Fatal("main changed")
|
||||
}
|
||||
for _, name := range []string{"../escape", "Order", "order_items", "func", "main", "init", "internal", "", strings.Repeat("a", 49)} {
|
||||
if err := AddModule(root, name); err == nil {
|
||||
t.Fatalf("accepted %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddModuleRejectsForeignProjectAndSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/foreign\ngo 1.22\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AddModule(root, "orders"); err == nil {
|
||||
t.Fatal("accepted foreign project")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "internal")); !os.IsNotExist(err) {
|
||||
t.Fatal("created files before validation")
|
||||
}
|
||||
root = moduleProject(t)
|
||||
if err := os.Rename(filepath.Join(root, "internal"), filepath.Join(root, "original")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "original"), filepath.Join(root, "internal")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AddModule(root, "orders"); err == nil {
|
||||
t.Fatal("accepted symlink parent")
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in because this compiles a separate consuming module and may need module
|
||||
// downloads. The Linux feature acceptance runner enables it explicitly.
|
||||
func TestAddModuleIntegration(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1 for generated-project CRUD")
|
||||
}
|
||||
root := moduleProject(t)
|
||||
if err := AddModule(root, "orderItems"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "internal/orderItems/module_test.go"), []byte(generatedModuleTest), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "./...")
|
||||
cmd.Dir = root
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("generated project: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
const generatedModuleTest = `package orderItems_test
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"example.com/billing/internal/orderItems"
|
||||
)
|
||||
func TestGeneratedCRUD(t *testing.T) {
|
||||
app:=omni.New()
|
||||
repo:=orderItems.NewMemory()
|
||||
orderItems.Register(app,repo,func(next omni.Handler)omni.Handler{return func(c *omni.Context)error{
|
||||
if c.Header("Authorization")!="Bearer demo" {return omni.Unauthorized("DENIED","denied")};return next(c)
|
||||
}})
|
||||
request:=func(method,path,body string,authorized bool)*httptest.ResponseRecorder{
|
||||
r:=httptest.NewRequest(method,path,strings.NewReader(body));r.Header.Set("Content-Type","application/json")
|
||||
if authorized {r.Header.Set("Authorization","Bearer demo")}
|
||||
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,r);return w
|
||||
}
|
||||
if w:=request("POST","/orderItems","{\"name\":\"first\"}",false);w.Code!=401 {t.Fatal(w.Code)}
|
||||
cases:=[]struct{method,path,body string;status int;contains string}{
|
||||
{"POST","/orderItems","{\"name\":\" first \"}",201,"createdAt"},
|
||||
{"GET","/orderItems/1","",200,"first"},
|
||||
{"PUT","/orderItems/1","{\"name\":\"changed\"}",200,"changed"},
|
||||
{"GET","/orderItems","",200,"changed"},
|
||||
{"POST","/orderItems","{\"name\":\" \"}",400,"INVALID_NAME"},
|
||||
{"DELETE","/orderItems/1","",204,""},
|
||||
{"GET","/orderItems/1","",404,"NOT_FOUND"},
|
||||
}
|
||||
for _,tc:=range cases{w:=request(tc.method,tc.path,tc.body,true);if w.Code!=tc.status||!strings.Contains(w.Body.String(),tc.contains){t.Fatalf("%s %s: %d %s",tc.method,tc.path,w.Code,w.Body.String())}}
|
||||
ctx,cancel:=context.WithCancel(context.Background());cancel()
|
||||
if _,err:=repo.List(ctx);err!=context.Canceled {t.Fatal(err)}
|
||||
}
|
||||
`
|
||||
15
cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl
Normal file
15
cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl
Normal file
@@ -0,0 +1,15 @@
|
||||
# {{.Name}}
|
||||
|
||||
在业务启动文件导入并注册:
|
||||
|
||||
```go
|
||||
import "{{.Module}}/internal/{{.Name}}"
|
||||
|
||||
{{.Name}}.Register(application, {{.Name}}.NewMemory())
|
||||
```
|
||||
|
||||
提供 `POST /{{.Name}}`、`GET /{{.Name}}`、`GET /{{.Name}}/:id`、`PUT /{{.Name}}/:id`、
|
||||
`DELETE /{{.Name}}/:id`。写入 JSON 为 `{"name":"example"}`;输出字段为 id、name、createdAt。
|
||||
代码按 Handler / Service / Repository / Model 分层,`Register` 可注入自己的 Repository 和认证中间件。
|
||||
内存示例不持久化、不提供分页;列表适合小规模演示。接实际业务时定义领域字段、分页、鉴权和数据库实现。
|
||||
生成器不会自动修改主入口、已有模块或数据库结构,也不会覆盖已有文件。
|
||||
52
cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl
Normal file
52
cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl
Normal file
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"{{.Module}}/internal/{{.Name}}/model"
|
||||
"{{.Module}}/internal/{{.Name}}/service"
|
||||
)
|
||||
|
||||
type Handler struct { service *service.Service }
|
||||
type WriteRequest struct { Name string `json:"name" binding:"required,max=200"` }
|
||||
func New(service *service.Service) *Handler {return &Handler{service:service}}
|
||||
func (h *Handler) Register(routes *omni.RouterGroup) {
|
||||
routes.POST("",h.Create)
|
||||
routes.GET("",h.List)
|
||||
routes.GET("/:id",h.Get)
|
||||
routes.PUT("/:id",h.Update)
|
||||
routes.DELETE("/:id",h.Delete)
|
||||
}
|
||||
func (h *Handler) Create(c *omni.Context) error {
|
||||
var request WriteRequest
|
||||
if err:=c.BindJSON(&request); err!=nil {return err}
|
||||
item,err:=h.service.Create(c.Context(),request.Name)
|
||||
if err!=nil {return publicError(err)}
|
||||
return c.Created(item)
|
||||
}
|
||||
func (h *Handler) Get(c *omni.Context) error {
|
||||
item,err:=h.service.Get(c.Context(),c.Param("id"))
|
||||
if err!=nil {return publicError(err)}
|
||||
return c.OK(item)
|
||||
}
|
||||
func (h *Handler) List(c *omni.Context) error {
|
||||
items,err:=h.service.List(c.Context())
|
||||
if err!=nil {return publicError(err)}
|
||||
return c.OK(items)
|
||||
}
|
||||
func (h *Handler) Update(c *omni.Context) error {
|
||||
var request WriteRequest
|
||||
if err:=c.BindJSON(&request); err!=nil {return err}
|
||||
item,err:=h.service.Update(c.Context(),c.Param("id"),request.Name)
|
||||
if err!=nil {return publicError(err)}
|
||||
return c.OK(item)
|
||||
}
|
||||
func (h *Handler) Delete(c *omni.Context) error {
|
||||
if err:=h.service.Delete(c.Context(),c.Param("id")); err!=nil {return publicError(err)}
|
||||
return c.NoContent()
|
||||
}
|
||||
func publicError(err error) error {
|
||||
if errors.Is(err,model.ErrNotFound) {return omni.NotFound("NOT_FOUND","record not found").WithCause(err)}
|
||||
if errors.Is(err,model.ErrInvalidName) {return omni.BadRequest("INVALID_NAME","name must contain 1..200 bytes").WithCause(err)}
|
||||
return err
|
||||
}
|
||||
15
cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl
Normal file
15
cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("{{.Name}}: record not found")
|
||||
var ErrInvalidName = errors.New("{{.Name}}: name must contain 1..200 bytes")
|
||||
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
19
cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl
Normal file
19
cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl
Normal file
@@ -0,0 +1,19 @@
|
||||
package {{.Name}}
|
||||
|
||||
import (
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"{{.Module}}/internal/{{.Name}}/handler"
|
||||
"{{.Module}}/internal/{{.Name}}/repository"
|
||||
"{{.Module}}/internal/{{.Name}}/service"
|
||||
)
|
||||
|
||||
type Repository = repository.Repository
|
||||
|
||||
func NewMemory() Repository { return repository.NewMemory() }
|
||||
|
||||
// Register wires this module with caller-owned storage. Pass auth middleware
|
||||
// here to apply it to every route before module-specific middleware.
|
||||
func Register(app *omni.App, repo Repository, middleware ...omni.Middleware) {
|
||||
if repo == nil { panic("{{.Name}}: repository is required") }
|
||||
handler.New(service.New(repo)).Register(app.Group("/{{.Name}}", middleware...))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
"{{.Module}}/internal/{{.Name}}/model"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(context.Context,string) (model.Item,error)
|
||||
Get(context.Context,string) (model.Item,error)
|
||||
List(context.Context) ([]model.Item,error)
|
||||
Update(context.Context,string,string) (model.Item,error)
|
||||
Delete(context.Context,string) error
|
||||
}
|
||||
|
||||
// Memory is demonstration storage; data and IDs reset with the process.
|
||||
type Memory struct { mu sync.Mutex; next uint64; items map[string]model.Item }
|
||||
func NewMemory() *Memory { return &Memory{items:make(map[string]model.Item)} }
|
||||
func (m *Memory) Create(ctx context.Context, name string) (model.Item,error) {
|
||||
m.mu.Lock(); defer m.mu.Unlock()
|
||||
if err:=ctx.Err(); err!=nil {return model.Item{},err}
|
||||
m.next++
|
||||
item:=model.Item{ID:strconv.FormatUint(m.next,10),Name:name,CreatedAt:time.Now().UTC()}
|
||||
m.items[item.ID]=item
|
||||
return item,nil
|
||||
}
|
||||
func (m *Memory) Get(ctx context.Context,id string) (model.Item,error) {
|
||||
m.mu.Lock(); defer m.mu.Unlock()
|
||||
if err:=ctx.Err(); err!=nil {return model.Item{},err}
|
||||
item,ok:=m.items[id]
|
||||
if !ok {return model.Item{},model.ErrNotFound}
|
||||
return item,nil
|
||||
}
|
||||
func (m *Memory) List(ctx context.Context) ([]model.Item,error) {
|
||||
m.mu.Lock(); defer m.mu.Unlock()
|
||||
if err:=ctx.Err(); err!=nil {return nil,err}
|
||||
items:=make([]model.Item,0,len(m.items))
|
||||
for _,item:=range m.items {items=append(items,item)}
|
||||
sort.Slice(items,func(i,j int)bool{return items[i].ID<items[j].ID})
|
||||
return items,nil
|
||||
}
|
||||
func (m *Memory) Update(ctx context.Context,id,name string) (model.Item,error) {
|
||||
m.mu.Lock(); defer m.mu.Unlock()
|
||||
if err:=ctx.Err(); err!=nil {return model.Item{},err}
|
||||
item,ok:=m.items[id]
|
||||
if !ok {return model.Item{},model.ErrNotFound}
|
||||
item.Name=name; m.items[id]=item
|
||||
return item,nil
|
||||
}
|
||||
func (m *Memory) Delete(ctx context.Context,id string) error {
|
||||
m.mu.Lock(); defer m.mu.Unlock()
|
||||
if err:=ctx.Err(); err!=nil {return err}
|
||||
if _,ok:=m.items[id]; !ok {return model.ErrNotFound}
|
||||
delete(m.items,id)
|
||||
return nil
|
||||
}
|
||||
27
cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl
Normal file
27
cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl
Normal file
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"{{.Module}}/internal/{{.Name}}/model"
|
||||
"{{.Module}}/internal/{{.Name}}/repository"
|
||||
)
|
||||
|
||||
type Service struct { repo repository.Repository }
|
||||
func New(repo repository.Repository) *Service { return &Service{repo:repo} }
|
||||
func name(value string) (string,error) {
|
||||
value=strings.TrimSpace(value)
|
||||
if len(value)==0 || len(value)>200 {return "",model.ErrInvalidName}
|
||||
return value,nil
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context,value string) (model.Item,error) {
|
||||
value,err:=name(value); if err!=nil {return model.Item{},err}
|
||||
return s.repo.Create(ctx,value)
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context,id string) (model.Item,error) { return s.repo.Get(ctx,id) }
|
||||
func (s *Service) List(ctx context.Context) ([]model.Item,error) { return s.repo.List(ctx) }
|
||||
func (s *Service) Update(ctx context.Context,id,value string) (model.Item,error) {
|
||||
value,err:=name(value); if err!=nil {return model.Item{},err}
|
||||
return s.repo.Update(ctx,id,value)
|
||||
}
|
||||
func (s *Service) Delete(ctx context.Context,id string) error {return s.repo.Delete(ctx,id)}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
//go:embed templates moduleTemplates
|
||||
var templateFiles embed.FS
|
||||
|
||||
// Options controls project generation.
|
||||
|
||||
@@ -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` 限制初始化检查和数据库探针的等待时间。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
examples/modules/README.md
Normal file
33
examples/modules/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 基础模块组合示例
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```sh
|
||||
go run ./examples/modules
|
||||
```
|
||||
|
||||
默认监听 `127.0.0.1:8080`,使用仅限开发的 `Bearer dev-token`。
|
||||
配置 `GO_OMNI_TRANSPORT=native` 可直接切换自研网络层,TLS 配置与主框架一致。
|
||||
|
||||
```sh
|
||||
curl -H 'Authorization: Bearer dev-token' http://127.0.0.1:8080/api/catalog
|
||||
curl -H 'Authorization: Bearer dev-token' -H 'Content-Type: application/json' \
|
||||
-H 'Idempotency-Key: report-1' -d '{"name":"report"}' http://127.0.0.1:8080/api/jobs
|
||||
curl -H 'Authorization: Bearer dev-token' http://127.0.0.1:8080/api/jobs
|
||||
```
|
||||
|
||||
catalog 缓存 30 秒;可通过启动环境变量 `GO_OMNI_DEMO_UPSTREAM` 指定可信 JSON 上游,
|
||||
演示 HTTP 连接池、请求 ID 传递与合并回源。URL 来自服务配置,不接受请求参数传入。
|
||||
|
||||
jobs 展示固定 worker、队列、结构化日志与状态查询;相同 Idempotency-Key 和请求内容重放
|
||||
第一次 202 响应,内容不同返回 409。每分钟的统计任务演示间隔调度。退出先排空任务,再关闭
|
||||
HTTP 客户端和缓存。这个示例的任务、缓存和幂等记录均保存在进程内。
|
||||
|
||||
组合测试通过真实 TLS 连接覆盖 nethttp、native 和实验 HTTP/2,检查缓存回源次数、
|
||||
幂等重放、认证不可被重放绕过、任务只提交一次与关闭流程:
|
||||
|
||||
```sh
|
||||
go test -race ./examples/modules
|
||||
```
|
||||
|
||||
完整 API、默认上限和适用范围见 [业务基础模块](../../MODULES.md)。
|
||||
133
examples/modules/main.go
Normal file
133
examples/modules/main.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// modules combines caching, outbound calls, middleware and background jobs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cache"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
"git.yuntuo.hk/k2safe/go-omni/task"
|
||||
)
|
||||
|
||||
func newApp(settings omni.Config, token, upstream string) (application *omni.App, err error) {
|
||||
application = omni.New(omni.WithConfig(settings))
|
||||
defer func(app *omni.App) {
|
||||
if err != nil {
|
||||
_ = app.Close()
|
||||
}
|
||||
}(application)
|
||||
store, err := cache.NewMemory(cache.MemoryConfig{MaxEntries: 128, MaxBytes: 1 << 20})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
application.OnShutdown(store.Close)
|
||||
loader, err := cache.NewLoader(store, cache.LoaderConfig{MaxLoads: 8, Timeout: time.Second})
|
||||
if err != nil {
|
||||
return application, err
|
||||
}
|
||||
client, err := httpc.New(httpc.Config{Timeout: time.Second, MaxRetries: 1, MaxConnsPerHost: 8})
|
||||
if err != nil {
|
||||
return application, err
|
||||
}
|
||||
application.OnShutdown(client.Close)
|
||||
jobs, err := task.New(task.Config{Workers: 2, QueueSize: 16, TaskTimeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
return application, err
|
||||
}
|
||||
// Reverse cleanup order: drain jobs before closing their dependencies.
|
||||
application.OnShutdown(jobs.Close)
|
||||
if err = jobs.Every("reportStats", time.Minute, func(ctx context.Context) error {
|
||||
logx.InfoContext(ctx, "jobs.stats", "stats", jobs.Stats())
|
||||
return nil
|
||||
}); err != nil {
|
||||
return application, err
|
||||
}
|
||||
limiter, err := middleware.NewRateLimiter(middleware.RateLimitConfig{Rate: 20, Burst: 40, MaxKeys: 128, Key: func(c *omni.Context) string { p, _ := auth.FromContext(c); return p.UserID }})
|
||||
if err != nil {
|
||||
return application, err
|
||||
}
|
||||
idempotency, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Scope: func(c *omni.Context) (string, error) { p, _ := auth.FromContext(c); return p.UserID, nil }})
|
||||
if err != nil {
|
||||
return application, err
|
||||
}
|
||||
api := application.Group("/api", middleware.Timeout(2*time.Second), auth.RequireAuth(auth.StaticAuthenticator{token: {UserID: "demo-user"}}), limiter.Middleware())
|
||||
api.GET("/catalog", func(c *omni.Context) error {
|
||||
value, err := loader.GetOrLoad(c.Context(), "publicCatalog:v1", 30*time.Second, func(ctx context.Context) ([]byte, error) {
|
||||
data := map[string]any{"name": "demo catalog"}
|
||||
if upstream != "" {
|
||||
if err := client.JSON(ctx, "GET", upstream, nil, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return json.Marshal(data)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(json.RawMessage(value))
|
||||
})
|
||||
var nextID atomic.Uint64
|
||||
api.POST("/jobs", func(c *omni.Context) error {
|
||||
var input struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
}
|
||||
if err := c.BindJSON(&input); err != nil {
|
||||
return err
|
||||
}
|
||||
id := nextID.Add(1)
|
||||
// This job deliberately outlives the HTTP request. Runner still applies
|
||||
// its own deadline and shutdown cancellation; log correlation survives.
|
||||
_, err := jobs.Submit(context.WithoutCancel(c.Context()), "demoJob", func(ctx context.Context) error {
|
||||
logx.InfoContext(ctx, "job.finished", "jobId", id, "name", input.Name)
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, task.ErrQueueFull) {
|
||||
return omni.NewError(503, "QUEUE_FULL", "job queue is full")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Accepted(map[string]uint64{"jobId": id})
|
||||
}, idempotency.Middleware())
|
||||
api.GET("/jobs", func(c *omni.Context) error { return c.OK(jobs.Stats()) })
|
||||
return application, nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context) error {
|
||||
settings, err := omni.LoadConfig("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if os.Getenv("GO_OMNI_HTTP_ADDR") == "" {
|
||||
settings.Address = "127.0.0.1:8080"
|
||||
}
|
||||
token := os.Getenv("GO_OMNI_DEV_TOKEN")
|
||||
if token == "" {
|
||||
token = "dev-token"
|
||||
}
|
||||
application, err := newApp(settings, token, os.Getenv("GO_OMNI_DEMO_UPSTREAM"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return application.RunContext(ctx)
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
126
examples/modules/main_test.go
Normal file
126
examples/modules/main_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||
)
|
||||
|
||||
// Exercise the composed feature chain through real sockets and all runners.
|
||||
func TestModulesConfiguredTransports(t *testing.T) {
|
||||
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative, omni.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
var loads atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
loads.Add(1)
|
||||
if r.Header.Get("X-Request-ID") == "" {
|
||||
t.Error("missing outbound correlation")
|
||||
}
|
||||
io.WriteString(w, `{"name":"upstream catalog"}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
settings, err := omni.LoadConfig("MODULES_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
settings.Transport = transport
|
||||
settings.TLS.CertFile = cert
|
||||
settings.TLS.KeyFile = key
|
||||
application, err := newApp(settings, "demo", upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
listener.Close()
|
||||
t.Error("server failed to stop")
|
||||
}
|
||||
}()
|
||||
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
request := func(method, path, body, key, requestID, token string) (*http.Response, string) {
|
||||
r, err := http.NewRequest(method, "https://"+listener.Addr().String()+path, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Idempotency-Key", key)
|
||||
r.Header.Set("X-Request-ID", requestID)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
response, err := client.Do(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.TLS == nil || len(response.TLS.VerifiedChains) == 0 {
|
||||
t.Fatal("unverified TLS")
|
||||
}
|
||||
if transport == omni.TransportHTTP2 && response.ProtoMajor != 2 {
|
||||
t.Fatal(response.Proto)
|
||||
}
|
||||
return response, string(data)
|
||||
}
|
||||
for n := 0; n < 2; n++ {
|
||||
response, body := request("GET", "/api/catalog", "", "", "catalogRequest", "demo")
|
||||
if response.StatusCode != 200 || !strings.Contains(body, "upstream catalog") {
|
||||
t.Fatal(response.StatusCode, body)
|
||||
}
|
||||
}
|
||||
if loads.Load() != 1 {
|
||||
t.Fatal("cache missed", loads.Load())
|
||||
}
|
||||
first, original := request("POST", "/api/jobs", `{"name":"report"}`, "job1", "firstRequest", "demo")
|
||||
second, replayed := request("POST", "/api/jobs", `{"name":"report"}`, "job1", "secondRequest", "demo")
|
||||
if first.StatusCode != 202 || second.StatusCode != 202 || original != replayed || second.Header.Get("Idempotency-Replayed") != "true" || second.Header.Get("X-Request-ID") != "firstRequest" {
|
||||
t.Fatal(first.StatusCode, second.StatusCode, original, replayed)
|
||||
}
|
||||
if response, _ := request("POST", "/api/jobs", `{"name":"other"}`, "job1", "conflictRequest", "demo"); response.StatusCode != 409 {
|
||||
t.Fatal(response.StatusCode)
|
||||
}
|
||||
if response, _ := request("POST", "/api/jobs", `{"name":"report"}`, "job1", "unauthorized", "wrong"); response.StatusCode != 401 {
|
||||
t.Fatal("replay bypassed authentication", response.StatusCode)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
response, body := request("GET", "/api/jobs", "", "", "statsRequest", "demo")
|
||||
if response.StatusCode != 200 {
|
||||
t.Fatal(response.StatusCode)
|
||||
}
|
||||
if strings.Contains(body, `"completed":1`) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("job did not finish once", body)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
182
examples/reference/transport_test.go
Normal file
182
examples/reference/transport_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/reference/repository"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||
)
|
||||
|
||||
// networkHandler runs the same business assertions through a real TCP client.
|
||||
// The application handler is only called by its configured server transport.
|
||||
type networkHandler struct {
|
||||
t *testing.T
|
||||
client *http.Client
|
||||
address, scheme, token, alpn string
|
||||
proto int
|
||||
requests *int
|
||||
}
|
||||
|
||||
func (h networkHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.t.Helper()
|
||||
req := r.Clone(r.Context())
|
||||
req.RequestURI = ""
|
||||
req.URL.Scheme, req.URL.Host, req.Host = h.scheme, h.address, h.address
|
||||
if h.token != "" && req.Header.Get("Authorization") == "" {
|
||||
req.Header.Set("Authorization", "Bearer "+h.token)
|
||||
}
|
||||
response, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if strings.HasPrefix(req.URL.Path, "/api/v1/") {
|
||||
*h.requests++
|
||||
}
|
||||
if response.ProtoMajor != h.proto {
|
||||
h.t.Fatalf("protocol %s, want HTTP/%d", response.Proto, h.proto)
|
||||
}
|
||||
if h.scheme == "https" && (response.TLS == nil || len(response.TLS.VerifiedChains) == 0 || response.TLS.NegotiatedProtocol != h.alpn) {
|
||||
h.t.Fatalf("TLS/ALPN verification failed: %+v", response.TLS)
|
||||
}
|
||||
for key, values := range response.Header {
|
||||
w.Header()[key] = append([]string(nil), values...)
|
||||
}
|
||||
w.WriteHeader(response.StatusCode)
|
||||
if _, err := io.Copy(w, response.Body); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type lockedLog struct {
|
||||
sync.Mutex
|
||||
bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedLog) Write(p []byte) (int, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedLog) snapshot() string {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return b.Buffer.String()
|
||||
}
|
||||
|
||||
func TestBusinessConfiguredTransports(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, transport string
|
||||
tls bool
|
||||
proto int
|
||||
alpn string
|
||||
}{
|
||||
{"nethttp", omni.TransportNetHTTP, false, 1, ""},
|
||||
{"native", omni.TransportNative, false, 1, ""},
|
||||
{"nethttpTLS", omni.TransportNetHTTP, true, 2, "h2"},
|
||||
{"nativeTLS", omni.TransportNative, true, 1, "http/1.1"},
|
||||
{"http2TLS", omni.TransportHTTP2, true, 2, "h2"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
t.Setenv("REFERENCE_TEST_TRANSPORT", test.transport)
|
||||
t.Setenv("REFERENCE_TEST_SHUTDOWN_TIMEOUT", "3s")
|
||||
t.Setenv("REFERENCE_TEST_TLS_CERT_FILE", "")
|
||||
t.Setenv("REFERENCE_TEST_TLS_KEY_FILE", "")
|
||||
if test.tls {
|
||||
t.Setenv("REFERENCE_TEST_TLS_CERT_FILE", cert)
|
||||
t.Setenv("REFERENCE_TEST_TLS_KEY_FILE", key)
|
||||
}
|
||||
settings, err := omni.LoadConfig("REFERENCE_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var logs lockedLog
|
||||
logger := testLogger(&logs)
|
||||
application := newProtectedApp(repository.NewMemoryUsers(), logger, auth.StaticAuthenticator{
|
||||
"admin": {UserID: "admin", Roles: []string{"admin"}},
|
||||
"user": {UserID: "user", Roles: []string{"user"}},
|
||||
}, omni.WithConfig(settings))
|
||||
application.OnShutdown(logger.Close)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- application.ServeContext(ctx, listener) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("business server did not stop")
|
||||
listener.Close()
|
||||
}
|
||||
})
|
||||
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
scheme := "http"
|
||||
if test.tls {
|
||||
scheme = "https"
|
||||
}
|
||||
requests := 0
|
||||
network := networkHandler{t: t, client: client, address: listener.Addr().String(), scheme: scheme, proto: test.proto, alpn: test.alpn, requests: &requests}
|
||||
request(t, network, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, 401, "UNAUTHENTICATED")
|
||||
requestWithAuthorization(t, network, "DELETE", "/api/v1/users/1", "", "Bearer user", 403, "FORBIDDEN")
|
||||
network.token = "admin"
|
||||
checkUserCRUD(t, network)
|
||||
request(t, network, "POST", "/api/v1/users", `{"name":`, 400, "INVALID_BODY")
|
||||
count := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(logs.snapshot()), "\n") {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record["requestId"] == "reference-test" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count < requests {
|
||||
t.Fatalf("missing correlated business logs: %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceAddressCompatibility(t *testing.T) {
|
||||
t.Setenv("GO_OMNI_TRANSPORT", "native")
|
||||
t.Setenv("GO_OMNI_TLS_CERT_FILE", "")
|
||||
t.Setenv("GO_OMNI_TLS_KEY_FILE", "")
|
||||
t.Setenv("GO_OMNI_HTTP_ADDR", "")
|
||||
t.Setenv("GO_OMNI_ADDR", "")
|
||||
settings, err := referenceConfig()
|
||||
if err != nil || settings.Address != "127.0.0.1:8080" || settings.Transport != "native" {
|
||||
t.Fatalf("default: %+v %v", settings, err)
|
||||
}
|
||||
t.Setenv("GO_OMNI_ADDR", "127.0.0.1:8081")
|
||||
settings, err = referenceConfig()
|
||||
if err != nil || settings.Address != "127.0.0.1:8081" {
|
||||
t.Fatalf("legacy: %+v %v", settings, err)
|
||||
}
|
||||
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:8082")
|
||||
settings, err = referenceConfig()
|
||||
if err != nil || settings.Address != "127.0.0.1:8082" {
|
||||
t.Fatalf("standard: %+v %v", settings, err)
|
||||
}
|
||||
}
|
||||
6
go.mod
6
go.mod
@@ -5,17 +5,21 @@ go 1.22
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
golang.org/x/mod v0.17.0
|
||||
golang.org/x/net v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
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
|
||||
)
|
||||
|
||||
12
go.sum
12
go.sum
@@ -1,7 +1,15 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -18,10 +26,14 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
|
||||
255
httpc/client.go
Normal file
255
httpc/client.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Package httpc provides a pooled HTTP client with an end-to-end deadline and
|
||||
// conservative, bounded retries. Redirects are returned to the caller.
|
||||
package httpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
var ErrClosed = errors.New("httpc: closed")
|
||||
var ErrResponseTooLarge = errors.New("httpc: response exceeds limit")
|
||||
|
||||
type Config struct {
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
MaxRetries int `json:"maxRetries"`
|
||||
Backoff time.Duration `json:"backoff"`
|
||||
MaxBackoff time.Duration `json:"maxBackoff"`
|
||||
MaxConnsPerHost int `json:"maxConnsPerHost"`
|
||||
MaxResponseBytes int64 `json:"maxResponseBytes"`
|
||||
DisableRequestID bool `json:"disableRequestId"`
|
||||
DisableLogging bool `json:"disableLogging"`
|
||||
Transport http.RoundTripper `json:"-"` // Borrowed. The caller owns its lifecycle.
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
config Config
|
||||
client *http.Client
|
||||
owned *http.Transport
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Client, error) {
|
||||
if cfg.Timeout < 0 || cfg.Backoff < 0 || cfg.MaxBackoff < 0 || cfg.MaxRetries < 0 || cfg.MaxRetries > 5 || cfg.MaxConnsPerHost < 0 || cfg.MaxResponseBytes < 0 {
|
||||
return nil, errors.New("httpc: invalid configuration")
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
if cfg.Backoff == 0 {
|
||||
cfg.Backoff = 100 * time.Millisecond
|
||||
}
|
||||
if cfg.MaxBackoff == 0 {
|
||||
cfg.MaxBackoff = 2 * time.Second
|
||||
}
|
||||
if cfg.Backoff > cfg.MaxBackoff {
|
||||
return nil, errors.New("httpc: backoff exceeds maxBackoff")
|
||||
}
|
||||
if cfg.MaxConnsPerHost == 0 {
|
||||
cfg.MaxConnsPerHost = 64
|
||||
}
|
||||
if cfg.MaxResponseBytes == 0 {
|
||||
cfg.MaxResponseBytes = 1 << 20
|
||||
}
|
||||
if cfg.MaxResponseBytes > 1<<30 || cfg.MaxConnsPerHost > 65536 {
|
||||
return nil, errors.New("httpc: response or connection limit exceeds supported capacity")
|
||||
}
|
||||
c := &Client{config: cfg}
|
||||
transport := cfg.Transport
|
||||
if transport == nil {
|
||||
// Own a private pool; never mutate the process-wide DefaultTransport.
|
||||
base, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("httpc: provide Transport when DefaultTransport is replaced")
|
||||
}
|
||||
c.owned = base.Clone()
|
||||
c.owned.MaxConnsPerHost = cfg.MaxConnsPerHost
|
||||
c.owned.MaxIdleConnsPerHost = cfg.MaxConnsPerHost
|
||||
c.owned.MaxIdleConns = cfg.MaxConnsPerHost * 2
|
||||
transport = c.owned
|
||||
}
|
||||
c.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Do keeps the total deadline active until the response body is closed or
|
||||
// consumed. Callers own response.Body. Only GET, HEAD and OPTIONS are retried,
|
||||
// and only when their bodies can be replayed. MaxRetries=0 disables retries.
|
||||
func (c *Client) Do(request *http.Request) (*http.Response, error) {
|
||||
var requestError error
|
||||
if c.closed.Load() {
|
||||
requestError = ErrClosed
|
||||
} else if request == nil || request.URL == nil {
|
||||
requestError = errors.New("httpc: nil request or URL")
|
||||
}
|
||||
if requestError != nil {
|
||||
// Match http.Client.Do's ownership rule even when rejection happens
|
||||
// before the underlying transport takes ownership of the body.
|
||||
if request != nil && request.Body != nil {
|
||||
_ = request.Body.Close()
|
||||
}
|
||||
return nil, requestError
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(request.Context(), c.config.Timeout)
|
||||
base := request.Clone(ctx)
|
||||
if base.Header == nil {
|
||||
base.Header = make(http.Header)
|
||||
}
|
||||
if !c.config.DisableRequestID && base.Header.Get("X-Request-ID") == "" {
|
||||
if id := logx.RequestID(ctx); id != "" {
|
||||
base.Header.Set("X-Request-ID", id)
|
||||
}
|
||||
}
|
||||
replayable := (base.Method == "" || base.Method == "GET" || base.Method == "HEAD" || base.Method == "OPTIONS") && (base.Body == nil || base.Body == http.NoBody || base.GetBody != nil)
|
||||
for attempt := 0; ; attempt++ {
|
||||
req := base.Clone(ctx)
|
||||
if attempt > 0 && base.Body != nil && base.Body != http.NoBody {
|
||||
body, err := base.GetBody()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
req.Body = body
|
||||
}
|
||||
started := time.Now()
|
||||
response, err := c.client.Do(req)
|
||||
status := 0
|
||||
if response != nil {
|
||||
status = response.StatusCode
|
||||
}
|
||||
if !c.config.DisableLogging {
|
||||
logx.InfoContext(ctx, "http.client", "method", req.Method, "host", req.URL.Hostname(), "status", status, "attempt", attempt+1, "durationMs", time.Since(started).Seconds()*1000, "failed", err != nil)
|
||||
}
|
||||
retry := replayable && attempt < c.config.MaxRetries && ctx.Err() == nil && (err != nil || status == 429 || status == 502 || status == 503 || status == 504)
|
||||
delay := c.config.Backoff
|
||||
for i := 0; i < attempt && delay < c.config.MaxBackoff; i++ {
|
||||
if delay > c.config.MaxBackoff/2 {
|
||||
delay = c.config.MaxBackoff
|
||||
} else {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
if response != nil && response.Header.Get("Retry-After") != "" {
|
||||
if value, ok := retryAfter(response.Header.Get("Retry-After")); ok {
|
||||
delay = value
|
||||
}
|
||||
} else if delay > 1 {
|
||||
delay = delay/2 + time.Duration(rand.Int63n(int64(delay/2)+1))
|
||||
}
|
||||
if delay > c.config.MaxBackoff {
|
||||
retry = false
|
||||
}
|
||||
if !retry {
|
||||
if err != nil {
|
||||
cancel()
|
||||
return response, err
|
||||
}
|
||||
response.Body = &responseBody{ReadCloser: response.Body, cancel: cancel}
|
||||
return response, nil
|
||||
}
|
||||
if response != nil {
|
||||
if response.ContentLength >= 0 && response.ContentLength <= 32<<10 {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
cancel()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type responseBody struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (b *responseBody) Read(data []byte) (int, error) {
|
||||
n, err := b.ReadCloser.Read(data)
|
||||
if err != nil {
|
||||
b.cancel()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
func (b *responseBody) Close() error { defer b.cancel(); return b.ReadCloser.Close() }
|
||||
|
||||
func retryAfter(value string) (time.Duration, bool) {
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 32); err == nil && n >= 0 {
|
||||
return time.Duration(n) * time.Second, true
|
||||
}
|
||||
if at, err := http.ParseTime(value); err == nil {
|
||||
return max(time.Until(at), 0), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
type StatusError struct{ StatusCode int }
|
||||
|
||||
func (e *StatusError) Error() string { return fmt.Sprintf("httpc: HTTP status %d", e.StatusCode) }
|
||||
|
||||
// JSON bounds response memory, closes the body, and decodes only 2xx responses.
|
||||
// Error bodies, URLs, credentials and query strings are not included in logs.
|
||||
func (c *Client) JSON(ctx context.Context, method, url string, input, output any) error {
|
||||
if ctx == nil {
|
||||
return errors.New("httpc: nil context")
|
||||
}
|
||||
var body io.Reader
|
||||
if input != nil {
|
||||
data, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if input != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := c.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return &StatusError{StatusCode: response.StatusCode}
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, c.config.MaxResponseBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(len(data)) > c.config.MaxResponseBytes {
|
||||
return ErrResponseTooLarge
|
||||
}
|
||||
if output == nil || len(bytes.TrimSpace(data)) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, output)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.closed.Store(true)
|
||||
if c.owned != nil {
|
||||
c.owned.CloseIdleConnections()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
156
httpc/client_test.go
Normal file
156
httpc/client_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package httpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
type trackedBody struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (b *trackedBody) Close() error { b.closed = true; return nil }
|
||||
|
||||
func TestRejectedRequestClosesBody(t *testing.T) {
|
||||
for _, closed := range []bool{false, true} {
|
||||
client, _ := New(Config{DisableLogging: true})
|
||||
body := &trackedBody{Reader: strings.NewReader("body")}
|
||||
request, _ := http.NewRequest("POST", "http://unused.invalid", body)
|
||||
if closed {
|
||||
client.Close()
|
||||
} else {
|
||||
request.URL = nil
|
||||
}
|
||||
_, err := client.Do(request)
|
||||
client.Close()
|
||||
if err == nil || !body.closed {
|
||||
t.Fatalf("error=%v bodyClosed=%v", err, body.closed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryCorrelationPoolAndWriteSafety(t *testing.T) {
|
||||
var calls, connections atomic.Int32
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if r.Header.Get("X-Request-ID") != "request-1" {
|
||||
t.Error("missing correlation")
|
||||
}
|
||||
if n == 1 || r.Method == "POST" {
|
||||
w.WriteHeader(503)
|
||||
io.WriteString(w, "busy")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
server.Config.ConnState = func(_ net.Conn, s http.ConnState) {
|
||||
if s == http.StateNew {
|
||||
connections.Add(1)
|
||||
}
|
||||
}
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
client, err := New(Config{MaxRetries: 2, Backoff: time.Millisecond, DisableLogging: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
ctx := logx.WithRequestID(context.Background(), "request-1")
|
||||
var output struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := client.JSON(ctx, "GET", server.URL, nil, &output); err != nil || !output.OK {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 2 || connections.Load() != 1 {
|
||||
t.Fatalf("calls=%d connections=%d", calls.Load(), connections.Load())
|
||||
}
|
||||
if err := client.JSON(ctx, "POST", server.URL, map[string]string{"name": "value"}, nil); err == nil {
|
||||
t.Fatal("expected 503")
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatal("POST was retried")
|
||||
}
|
||||
client.Close()
|
||||
if err := client.JSON(ctx, "GET", server.URL, nil, nil); !errors.Is(err, ErrClosed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalDeadlineIncludesBackoffAndBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/retry" {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(Config{Timeout: 40 * time.Millisecond, MaxRetries: 3, DisableLogging: true})
|
||||
defer client.Close()
|
||||
started := time.Now()
|
||||
err := client.JSON(context.Background(), "GET", server.URL+"/retry", nil, nil)
|
||||
if !errors.Is(err, context.DeadlineExceeded) || time.Since(started) > time.Second {
|
||||
t.Fatal(err, time.Since(started))
|
||||
}
|
||||
req, _ := http.NewRequest("GET", server.URL+"/body", nil)
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if _, err := io.ReadAll(response.Body); err == nil {
|
||||
t.Fatal("body deadline was canceled too early")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitsRedirectAndNonReplayableBody(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
switch r.URL.Path {
|
||||
case "/redirect":
|
||||
http.Redirect(w, r, "/large", 302)
|
||||
case "/large":
|
||||
io.WriteString(w, strings.Repeat("x", 17))
|
||||
default:
|
||||
w.WriteHeader(503)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(Config{MaxRetries: 2, MaxResponseBytes: 16, DisableLogging: true})
|
||||
defer client.Close()
|
||||
if err := client.JSON(context.Background(), "GET", server.URL+"/large", nil, nil); !errors.Is(err, ErrResponseTooLarge) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status *StatusError
|
||||
if err := client.JSON(context.Background(), "GET", server.URL+"/redirect", nil, nil); !errors.As(err, &status) || status.StatusCode != 302 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatal("followed redirect")
|
||||
}
|
||||
req, _ := http.NewRequest("GET", server.URL, io.NopCloser(strings.NewReader("body")))
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if calls.Load() != 3 {
|
||||
t.Fatal("non-replayable body was retried")
|
||||
}
|
||||
}
|
||||
105
httpx/clientIP.go
Normal file
105
httpx/clientIP.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientIPResolver trusts X-Forwarded-For only from explicitly configured
|
||||
// proxy addresses. It is immutable and safe to share between requests.
|
||||
// The zero value (and a nil resolver) trusts no proxy.
|
||||
type ClientIPResolver struct {
|
||||
trusted []netip.Prefix
|
||||
}
|
||||
|
||||
// NewClientIPResolver accepts IP addresses or CIDR networks, never hostnames.
|
||||
func NewClientIPResolver(proxies ...string) (*ClientIPResolver, error) {
|
||||
r := &ClientIPResolver{}
|
||||
for _, value := range proxies {
|
||||
value = strings.TrimSpace(value)
|
||||
if address, err := netip.ParseAddr(value); err == nil && address.Zone() == "" {
|
||||
address = address.Unmap()
|
||||
r.trusted = append(r.trusted, netip.PrefixFrom(address, address.BitLen()))
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trusted proxy %q: expected an IP address or CIDR", value)
|
||||
}
|
||||
if prefix.Addr().Is4In6() {
|
||||
if prefix.Bits() < 96 {
|
||||
return nil, fmt.Errorf("invalid trusted proxy %q: mapped IPv4 prefix must be at least /96", value)
|
||||
}
|
||||
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
|
||||
}
|
||||
r.trusted = append(r.trusted, prefix.Masked())
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *ClientIPResolver) trusts(address netip.Addr) bool {
|
||||
if r != nil {
|
||||
for _, prefix := range r.trusted {
|
||||
if prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClientIP walks X-Forwarded-For from right to left, stopping at the first
|
||||
// untrusted address. Missing or malformed forwarding data falls back to the
|
||||
// socket peer. X-Real-IP, Forwarded and provider-specific headers are ignored.
|
||||
func (r *ClientIPResolver) ClientIP(request *http.Request) string {
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
host := request.RemoteAddr
|
||||
if value, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = value
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
peer = peer.WithZone("").Unmap()
|
||||
if !r.trusts(peer) {
|
||||
return peer.String()
|
||||
}
|
||||
values := request.Header.Values("X-Forwarded-For")
|
||||
var chain []netip.Addr
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
address, err := netip.ParseAddr(strings.TrimSpace(part))
|
||||
if err != nil || address.Zone() != "" {
|
||||
return peer.String()
|
||||
}
|
||||
chain = append(chain, address.Unmap())
|
||||
}
|
||||
}
|
||||
current := peer
|
||||
for i := len(chain) - 1; i >= 0 && r.trusts(current); i-- {
|
||||
current = chain[i]
|
||||
}
|
||||
return current.String()
|
||||
}
|
||||
|
||||
type clientIPResolverKey struct{}
|
||||
|
||||
// WithClientIPResolver configures ClientIP for Context values derived from ctx.
|
||||
// Adapters should set this before running application middleware.
|
||||
func WithClientIPResolver(ctx context.Context, resolver *ClientIPResolver) context.Context {
|
||||
return context.WithValue(ctx, clientIPResolverKey{}, resolver)
|
||||
}
|
||||
|
||||
// ClientIP returns the verified client address without a port. By default it
|
||||
// returns the socket peer; App's trusted proxy configuration enables XFF.
|
||||
func (c *Context) ClientIP() string {
|
||||
resolver, _ := c.Context().Value(clientIPResolverKey{}).(*ClientIPResolver)
|
||||
return resolver.ClientIP(c.request)
|
||||
}
|
||||
76
httpx/clientIP_test.go
Normal file
76
httpx/clientIP_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package httpx_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
func TestClientIPResolverUsesSocketPeerByDefault(t *testing.T) {
|
||||
resolver, err := httpx.NewClientIPResolver()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := newClientIPRequest("192.0.2.10:443", "198.51.100.7")
|
||||
if got := resolver.ClientIP(request); got != "192.0.2.10" {
|
||||
t.Fatalf("untrusted forwarding header changed client IP: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPResolverWalksTrustedForwardingChain(t *testing.T) {
|
||||
resolver, err := httpx.NewClientIPResolver("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := newClientIPRequest("10.0.0.5:443", "198.51.100.7, 10.0.0.4")
|
||||
if got := resolver.ClientIP(request); got != "198.51.100.7" {
|
||||
t.Fatalf("expected original client IP, got %q", got)
|
||||
}
|
||||
|
||||
request = newClientIPRequest("10.0.0.5:443", "198.51.100.7, 203.0.113.9, 10.0.0.4")
|
||||
if got := resolver.ClientIP(request); got != "203.0.113.9" {
|
||||
t.Fatalf("expected first untrusted hop, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPResolverRejectsMalformedForwardingData(t *testing.T) {
|
||||
resolver, err := httpx.NewClientIPResolver("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := newClientIPRequest("127.0.0.1:443", "198.51.100.7, not-an-ip")
|
||||
if got := resolver.ClientIP(request); got != "127.0.0.1" {
|
||||
t.Fatalf("malformed forwarding header should fall back to peer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientIPResolverRejectsHostnames(t *testing.T) {
|
||||
if _, err := httpx.NewClientIPResolver("proxy.example.com"); err == nil || !strings.Contains(err.Error(), "invalid trusted proxy") {
|
||||
t.Fatalf("expected hostname validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextClientIPUsesResolverFromRequestContext(t *testing.T) {
|
||||
resolver, err := httpx.NewClientIPResolver("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := newClientIPRequest("127.0.0.1:443", "198.51.100.7")
|
||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), resolver))
|
||||
ctx := httpx.NewContext(request, httptest.NewRecorder(), nil)
|
||||
if got := ctx.ClientIP(); got != "198.51.100.7" {
|
||||
t.Fatalf("expected context client IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newClientIPRequest(remote, forwarded string) *http.Request {
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = remote
|
||||
if forwarded != "" {
|
||||
request.Header.Set("X-Forwarded-For", forwarded)
|
||||
}
|
||||
return request
|
||||
}
|
||||
@@ -117,6 +117,37 @@ func (c *Context) Context() context.Context {
|
||||
// Prefer the transport-neutral methods on Context for portable handlers.
|
||||
func (c *Context) Raw() http.ResponseWriter { return c.writer }
|
||||
|
||||
// Clone gives a buffering middleware its own response state and values map.
|
||||
// Stored values themselves are shared; middleware must not mutate them concurrently.
|
||||
func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context {
|
||||
clone := NewContext(c.request.Clone(ctx), writer, c.params)
|
||||
for key, value := range c.values {
|
||||
clone.values[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// WriteResponse commits a buffered response through GoOmni's response tracking.
|
||||
func (c *Context) WriteResponse(status int, headers http.Header, body []byte) error {
|
||||
if c.responded {
|
||||
return nil
|
||||
}
|
||||
if c.writer == nil {
|
||||
return apierrors.Internal(nil)
|
||||
}
|
||||
for key, values := range headers {
|
||||
c.writer.Header()[key] = append([]string(nil), values...)
|
||||
}
|
||||
c.responded, c.status = true, status
|
||||
c.writer.WriteHeader(status)
|
||||
if len(body) > 0 {
|
||||
if _, err := c.writer.Write(body); err != nil {
|
||||
return apierrors.Internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Context) Param(name string) string { return c.params[name] }
|
||||
|
||||
func (c *Context) Query(name string) string {
|
||||
|
||||
@@ -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
61
internal/testutil/tls.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Package testutil contains fixtures shared by transport integration tests.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertificateFiles creates a temporary localhost certificate and a client
|
||||
// configuration that verifies it. No insecure TLS bypass is needed in tests.
|
||||
func CertificateFiles(t testing.TB) (certFile, keyFile string, client *tls.Config) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
directory := t.TempDir()
|
||||
certFile, keyFile = filepath.Join(directory, "server.crt"), filepath.Join(directory, "server.key")
|
||||
if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
return certFile, keyFile, &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
106
metrics/metrics.go
Normal file
106
metrics/metrics.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package metrics provides a small transport-neutral request metrics collector.
|
||||
// It is intentionally in-memory and dependency-free; applications can expose
|
||||
// Snapshot through any route or export it to their monitoring system.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
// Snapshot is a point-in-time view of request metrics collected by Metrics.
|
||||
type Snapshot struct {
|
||||
Requests uint64 `json:"requests"`
|
||||
Errors uint64 `json:"errors"`
|
||||
InFlight int64 `json:"inFlight"`
|
||||
TotalDurationMs float64 `json:"totalDurationMs"`
|
||||
StatusCounts map[string]uint64 `json:"statusCounts"`
|
||||
}
|
||||
|
||||
// Metrics collects request counts, status counts, in-flight requests and
|
||||
// cumulative handler time. A zero Metrics value is ready for use.
|
||||
type Metrics struct {
|
||||
requests atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
inFlight atomic.Int64
|
||||
durationNs atomic.Int64
|
||||
statuses [600]atomic.Uint64
|
||||
}
|
||||
|
||||
// New creates an empty metrics collector.
|
||||
func New() *Metrics { return &Metrics{} }
|
||||
|
||||
// Middleware records every request that reaches the middleware chain. Errors
|
||||
// include returned handler errors and responses with a status of 400 or greater.
|
||||
func (m *Metrics) Middleware() httpx.Middleware {
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(ctx *httpx.Context) error {
|
||||
if m == nil {
|
||||
return next(ctx)
|
||||
}
|
||||
m.inFlight.Add(1)
|
||||
started := time.Now()
|
||||
err := next(ctx)
|
||||
status := ctx.Status()
|
||||
if err != nil && !ctx.Responded() {
|
||||
status = apierrors.From(err).Status
|
||||
}
|
||||
m.requests.Add(1)
|
||||
if err != nil || status >= 400 {
|
||||
m.errors.Add(1)
|
||||
}
|
||||
if status >= 0 && status < len(m.statuses) {
|
||||
m.statuses[status].Add(1)
|
||||
}
|
||||
m.durationNs.Add(time.Since(started).Nanoseconds())
|
||||
m.inFlight.Add(-1)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler exposes the current snapshot through the standard GoOmni response
|
||||
// envelope. Register it explicitly, for example app.GET("/metrics", m.Handler()).
|
||||
func (m *Metrics) Handler() httpx.Handler {
|
||||
return func(ctx *httpx.Context) error {
|
||||
return ctx.OK(m.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns a consistent-enough point-in-time view for operational
|
||||
// reporting. Counters may advance while the snapshot is being assembled.
|
||||
func (m *Metrics) Snapshot() Snapshot {
|
||||
if m == nil {
|
||||
return Snapshot{StatusCounts: map[string]uint64{}}
|
||||
}
|
||||
statuses := make(map[string]uint64)
|
||||
for status := 100; status < len(m.statuses); status++ {
|
||||
if count := m.statuses[status].Load(); count > 0 {
|
||||
statuses[itoa(status)] = count
|
||||
}
|
||||
}
|
||||
return Snapshot{
|
||||
Requests: m.requests.Load(),
|
||||
Errors: m.errors.Load(),
|
||||
InFlight: m.inFlight.Load(),
|
||||
TotalDurationMs: float64(m.durationNs.Load()) / float64(time.Millisecond),
|
||||
StatusCounts: statuses,
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var digits [3]byte
|
||||
index := len(digits)
|
||||
for value > 0 {
|
||||
index--
|
||||
digits[index] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits[index:])
|
||||
}
|
||||
67
metrics/metrics_test.go
Normal file
67
metrics/metrics_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/metrics"
|
||||
)
|
||||
|
||||
func TestMiddlewareCollectsStatusesAndErrors(t *testing.T) {
|
||||
collector := metrics.New()
|
||||
request := httptest.NewRequest("GET", "/health", nil)
|
||||
first := httptest.NewRecorder()
|
||||
ctx := httpx.NewContext(request, first, nil)
|
||||
if err := collector.Middleware()(func(c *httpx.Context) error { return c.OK("ok") })(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
ctx = httpx.NewContext(request, second, nil)
|
||||
handled := collector.Middleware()(func(*httpx.Context) error {
|
||||
return apierrors.NotFound("MISSING", "missing")
|
||||
})
|
||||
if err := handled(ctx); err == nil {
|
||||
t.Fatal("expected handler error")
|
||||
}
|
||||
|
||||
snapshot := collector.Snapshot()
|
||||
if snapshot.Requests != 2 || snapshot.Errors != 1 || snapshot.InFlight != 0 || snapshot.StatusCounts["200"] != 1 || snapshot.StatusCounts["404"] != 1 {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReturnsJSONSnapshot(t *testing.T) {
|
||||
collector := metrics.New()
|
||||
request := httptest.NewRequest("GET", "/metrics", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx := httpx.NewContext(request, recorder, nil)
|
||||
if err := collector.Handler()(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Data metrics.Snapshot `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Code != "OK" || envelope.Data.StatusCounts == nil {
|
||||
t.Fatalf("unexpected metrics response: %s", recorder.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewarePreservesApplicationErrors(t *testing.T) {
|
||||
collector := metrics.New()
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
ctx := httpx.NewContext(request, httptest.NewRecorder(), nil)
|
||||
want := errors.New("database unavailable")
|
||||
got := collector.Middleware()(func(*httpx.Context) error { return want })(ctx)
|
||||
if !errors.Is(got, want) {
|
||||
t.Fatalf("middleware replaced error: %v", got)
|
||||
}
|
||||
}
|
||||
235
middleware/idempotency.go
Normal file
235
middleware/idempotency.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
type IdempotencyConfig struct {
|
||||
TTL time.Duration `json:"ttl"`
|
||||
MaxEntries int `json:"maxEntries"`
|
||||
MaxBytes int `json:"maxBytes"`
|
||||
MaxRequestBytes int64 `json:"maxRequestBytes"`
|
||||
MaxResponseBytes int `json:"maxResponseBytes"`
|
||||
MaxInFlight int `json:"maxInFlight"`
|
||||
// Scope must return a server-verified tenant/user identity. Register auth
|
||||
// before this middleware. Client-provided identity is not authorization.
|
||||
Scope func(*httpx.Context) (string, error) `json:"-"`
|
||||
}
|
||||
|
||||
type replay struct {
|
||||
fingerprint [32]byte
|
||||
status int
|
||||
headers http.Header
|
||||
body []byte
|
||||
expires time.Time
|
||||
running, failed bool
|
||||
size int
|
||||
}
|
||||
type Idempotency struct {
|
||||
mu sync.Mutex
|
||||
config IdempotencyConfig
|
||||
items map[[32]byte]*replay
|
||||
bytes int
|
||||
slots chan struct{}
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewIdempotency(cfg IdempotencyConfig) (*Idempotency, error) {
|
||||
if cfg.Scope == nil || cfg.TTL < 0 || cfg.MaxEntries < 0 || cfg.MaxBytes < 0 || cfg.MaxRequestBytes < 0 || cfg.MaxResponseBytes < 0 || cfg.MaxInFlight < 0 {
|
||||
return nil, errors.New("middleware: invalid idempotency configuration or missing scope")
|
||||
}
|
||||
if cfg.TTL == 0 {
|
||||
cfg.TTL = 10 * time.Minute
|
||||
}
|
||||
if cfg.MaxEntries == 0 {
|
||||
cfg.MaxEntries = 256
|
||||
}
|
||||
if cfg.MaxBytes == 0 {
|
||||
cfg.MaxBytes = 16 << 20
|
||||
}
|
||||
if cfg.MaxRequestBytes == 0 {
|
||||
cfg.MaxRequestBytes = 1 << 20
|
||||
}
|
||||
if cfg.MaxResponseBytes == 0 {
|
||||
cfg.MaxResponseBytes = 64 << 10
|
||||
}
|
||||
if cfg.MaxInFlight == 0 {
|
||||
cfg.MaxInFlight = 32
|
||||
}
|
||||
if cfg.MaxRequestBytes > 1<<30 {
|
||||
return nil, errors.New("middleware: maxRequestBytes exceeds 1 GiB")
|
||||
}
|
||||
return &Idempotency{config: cfg, items: make(map[[32]byte]*replay), slots: make(chan struct{}, cfg.MaxInFlight), now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (i *Idempotency) Middleware() httpx.Middleware {
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
r := c.Request()
|
||||
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
|
||||
return next(c)
|
||||
}
|
||||
select {
|
||||
case i.slots <- struct{}{}:
|
||||
defer func() { <-i.slots }()
|
||||
default:
|
||||
return apierrors.New(503, "IDEMPOTENCY_BUSY", "idempotency capacity reached")
|
||||
}
|
||||
key := r.Header.Get("Idempotency-Key")
|
||||
if len(r.Header.Values("Idempotency-Key")) != 1 || len(key) < 1 || len(key) > 128 || strings.IndexFunc(key, func(r rune) bool { return r < 33 || r > 126 }) >= 0 {
|
||||
return apierrors.BadRequest("INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must contain 1..128 printable ASCII characters")
|
||||
}
|
||||
scope, err := i.config.Scope(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if scope == "" || len(scope) > 1024 {
|
||||
return apierrors.Unauthorized("IDEMPOTENCY_SCOPE_REQUIRED", "authenticated scope is required")
|
||||
}
|
||||
if r.ContentLength > i.config.MaxRequestBytes {
|
||||
return apierrors.New(413, "BODY_TOO_LARGE", "request body too large")
|
||||
}
|
||||
var body []byte
|
||||
if r.Body != nil {
|
||||
body, err = io.ReadAll(io.LimitReader(r.Body, i.config.MaxRequestBytes+1))
|
||||
if err != nil {
|
||||
return apierrors.BadRequest("INVALID_BODY", "unable to read request body").WithCause(err)
|
||||
}
|
||||
if int64(len(body)) > i.config.MaxRequestBytes {
|
||||
return apierrors.New(413, "BODY_TOO_LARGE", "request body too large")
|
||||
}
|
||||
}
|
||||
if err := c.Context().Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
originalBody := r.Body
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
defer func() { r.Body = originalBody }()
|
||||
parts, _ := json.Marshal([]string{r.Method, r.URL.RequestURI(), r.Header.Get("Content-Type")})
|
||||
hash := sha256.New()
|
||||
hash.Write(parts)
|
||||
hash.Write(body)
|
||||
var fingerprint [32]byte
|
||||
copy(fingerprint[:], hash.Sum(nil))
|
||||
namespace, _ := json.Marshal([]string{scope, key})
|
||||
id := sha256.Sum256(namespace)
|
||||
i.mu.Lock()
|
||||
now := i.now()
|
||||
for k, v := range i.items {
|
||||
if !v.running && !now.Before(v.expires) {
|
||||
delete(i.items, k)
|
||||
i.bytes -= v.size
|
||||
}
|
||||
}
|
||||
if saved := i.items[id]; saved != nil {
|
||||
if saved.fingerprint != fingerprint {
|
||||
i.mu.Unlock()
|
||||
return apierrors.New(409, "IDEMPOTENCY_CONFLICT", "key was used with a different request")
|
||||
}
|
||||
if saved.running || saved.failed {
|
||||
i.mu.Unlock()
|
||||
return apierrors.New(409, "IDEMPOTENCY_UNAVAILABLE", "request is in progress or its outcome cannot be replayed")
|
||||
}
|
||||
status, headers, data := saved.status, saved.headers.Clone(), append([]byte(nil), saved.body...)
|
||||
i.mu.Unlock()
|
||||
headers.Set("Idempotency-Replayed", "true")
|
||||
return c.WriteResponse(status, headers, data)
|
||||
}
|
||||
if len(i.items) >= i.config.MaxEntries {
|
||||
i.mu.Unlock()
|
||||
return apierrors.New(503, "IDEMPOTENCY_BUSY", "idempotency capacity reached")
|
||||
}
|
||||
saved := &replay{fingerprint: fingerprint, running: true}
|
||||
i.items[id] = saved
|
||||
i.mu.Unlock()
|
||||
buffer := &boundedResponse{headers: c.Raw().Header().Clone(), limit: i.config.MaxResponseBytes}
|
||||
// Always retain an outcome tombstone on panic/error/overflow. Deleting
|
||||
// the key could execute a partially committed business operation twice.
|
||||
defer func() {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
saved.running = false
|
||||
saved.failed = true
|
||||
saved.expires = i.now().Add(i.config.TTL)
|
||||
size := buffer.body.Len()
|
||||
for k, values := range buffer.headers {
|
||||
size += len(k)
|
||||
for _, v := range values {
|
||||
size += len(v)
|
||||
}
|
||||
}
|
||||
if buffer.complete && buffer.err == nil && buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") && size <= i.config.MaxBytes-i.bytes {
|
||||
saved.failed = false
|
||||
saved.status = buffer.status
|
||||
saved.headers = buffer.headers.Clone()
|
||||
saved.body = append([]byte(nil), buffer.body.Bytes()...)
|
||||
saved.size = size
|
||||
i.bytes += size
|
||||
}
|
||||
}()
|
||||
child := c.Clone(c.Context(), buffer)
|
||||
err = next(child)
|
||||
// Keep an ambiguous outcome tombstone, but let an outer Timeout
|
||||
// middleware turn deadline expiry into 504 before committing bytes.
|
||||
if ctxErr := child.Context().Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
child.WriteError(err)
|
||||
}
|
||||
if buffer.err != nil {
|
||||
return apierrors.Internal(buffer.err)
|
||||
}
|
||||
if buffer.status == 0 {
|
||||
buffer.status = 200
|
||||
}
|
||||
buffer.complete = true
|
||||
return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally lacks Flush/Hijack: only buffered, bounded endpoints are valid.
|
||||
type boundedResponse struct {
|
||||
headers http.Header
|
||||
status, limit int
|
||||
body bytes.Buffer
|
||||
err error
|
||||
complete bool
|
||||
}
|
||||
|
||||
func (b *boundedResponse) Header() http.Header { return b.headers }
|
||||
func (b *boundedResponse) WriteHeader(status int) {
|
||||
if status < 200 {
|
||||
b.err = errors.New("idempotency: informational/upgrade responses are unsupported")
|
||||
return
|
||||
}
|
||||
if b.status == 0 {
|
||||
b.status = status
|
||||
}
|
||||
}
|
||||
func (b *boundedResponse) Write(data []byte) (int, error) {
|
||||
if b.status == 0 {
|
||||
b.status = 200
|
||||
}
|
||||
if b.err != nil {
|
||||
return 0, b.err
|
||||
}
|
||||
if len(data) > b.limit-b.body.Len() {
|
||||
b.err = fmt.Errorf("idempotency: response exceeds %d bytes", b.limit)
|
||||
return 0, b.err
|
||||
}
|
||||
return b.body.Write(data)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
227
middleware/protection_test.go
Normal file
227
middleware/protection_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
func invoke(h httpx.Handler, method, body, scope, key string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, "/records?version=1", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if key != "" {
|
||||
r.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c := httpx.NewContext(r, w, nil)
|
||||
c.Set("verifiedIdentity", scope)
|
||||
if err := h(c); err != nil {
|
||||
c.WriteError(err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func identity(c *httpx.Context) (string, error) {
|
||||
v, _ := c.Get("verifiedIdentity")
|
||||
s, _ := v.(string)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func TestRateLimitRefillCapacityAndIdleExpiry(t *testing.T) {
|
||||
l, err := NewRateLimiter(RateLimitConfig{Rate: 2, Burst: 2, MaxKeys: 1, IdleTTL: time.Second, Key: func(c *httpx.Context) string { s, _ := identity(c); return s }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(100, 0)
|
||||
l.now = func() time.Time { return now }
|
||||
h := l.Middleware()(func(c *httpx.Context) error { return c.NoContent() })
|
||||
for _, status := range []int{204, 204, 429} {
|
||||
w := invoke(h, "GET", "", "a", "")
|
||||
if w.Code != status {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if status == 429 && w.Header().Get("Retry-After") != "1" {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
}
|
||||
if w := invoke(h, "GET", "", "b", ""); w.Code != 503 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
now = now.Add(500 * time.Millisecond)
|
||||
if w := invoke(h, "GET", "", "a", ""); w.Code != 204 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
if w := invoke(h, "GET", "", "b", ""); w.Code != 204 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if _, err := NewRateLimiter(RateLimitConfig{Rate: 1, Burst: 2, IdleTTL: time.Second}); err == nil {
|
||||
t.Fatal("early bucket expiry accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitConcurrentBurst(t *testing.T) {
|
||||
l, _ := NewRateLimiter(RateLimitConfig{Rate: 1, Burst: 7})
|
||||
l.now = func() time.Time { return time.Unix(100, 0) }
|
||||
var allowed atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 100; n++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
delay, full := l.allow("same")
|
||||
if delay == 0 && !full {
|
||||
allowed.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if allowed.Load() != 7 {
|
||||
t.Fatal(allowed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutCooperativeAndCommittedResponse(t *testing.T) {
|
||||
for _, committed := range []bool{false, true} {
|
||||
h := Timeout(5 * time.Millisecond)(func(c *httpx.Context) error {
|
||||
if committed {
|
||||
_ = c.Accepted("accepted")
|
||||
}
|
||||
<-c.Context().Done()
|
||||
return c.Context().Err()
|
||||
})
|
||||
w := invoke(h, "GET", "", "", "")
|
||||
want := 504
|
||||
if committed {
|
||||
want = 202
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyReplayScopeConflictAndExpiry(t *testing.T) {
|
||||
i, err := NewIdempotency(IdempotencyConfig{Scope: identity, TTL: time.Minute})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(100, 0)
|
||||
i.now = func() time.Time { return now }
|
||||
calls := 0
|
||||
h := i.Middleware()(func(c *httpx.Context) error { calls++; return c.Created(map[string]int{"calls": calls}) })
|
||||
first := invoke(h, "POST", `{"name":"one"}`, "alice", "key1")
|
||||
replay := invoke(h, "POST", `{"name":"one"}`, "alice", "key1")
|
||||
if first.Code != 201 || replay.Code != 201 || first.Body.String() != replay.Body.String() || replay.Header().Get("Idempotency-Replayed") != "true" || calls != 1 {
|
||||
t.Fatal(first, replay, calls)
|
||||
}
|
||||
if w := invoke(h, "POST", `{"name":"two"}`, "alice", "key1"); w.Code != 409 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if w := invoke(h, "POST", `{"name":"one"}`, "bob", "key1"); w.Code != 201 || calls != 2 {
|
||||
t.Fatal(w.Code, calls)
|
||||
}
|
||||
if w := invoke(h, "POST", `{}`, "", "key1"); w.Code != 401 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if w := invoke(h, "POST", `{}`, "alice", ""); w.Code != 400 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if w := invoke(h, "POST", `{"name":"one"}`, "alice", "key1"); w.Code != 201 || calls != 3 {
|
||||
t.Fatal(w.Code, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyActiveClaimNeverExpires(t *testing.T) {
|
||||
i, _ := NewIdempotency(IdempotencyConfig{Scope: identity, TTL: time.Nanosecond, MaxInFlight: 2})
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
h := i.Middleware()(func(c *httpx.Context) error { calls.Add(1); close(started); <-release; return c.Created("saved") })
|
||||
done := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { done <- invoke(h, "POST", "{}", "alice", "key") }()
|
||||
<-started
|
||||
for n := 0; n < 30; n++ {
|
||||
if w := invoke(h, "POST", "{}", "alice", "key"); w.Code != 409 {
|
||||
t.Errorf("duplicate status %d", w.Code)
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
if w := <-done; w.Code != 201 || calls.Load() != 1 {
|
||||
t.Fatal(w.Code, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyBoundsAndAmbiguousOutcomes(t *testing.T) {
|
||||
for _, mode := range []string{"panic", "error", "overflow", "cookie", "noStore", "memory"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
cfg := IdempotencyConfig{Scope: identity, MaxRequestBytes: 8, MaxResponseBytes: 128, MaxEntries: 1}
|
||||
if mode == "memory" {
|
||||
cfg.MaxBytes = 1
|
||||
}
|
||||
i, _ := NewIdempotency(cfg)
|
||||
calls := 0
|
||||
h := Recovery()(i.Middleware()(func(c *httpx.Context) error {
|
||||
calls++
|
||||
switch mode {
|
||||
case "panic":
|
||||
panic("failure")
|
||||
case "error":
|
||||
return errors.New("failed")
|
||||
case "overflow":
|
||||
return c.OK(strings.Repeat("x", 200))
|
||||
case "cookie":
|
||||
c.SetResponseHeader("Set-Cookie", "session=secret")
|
||||
case "noStore":
|
||||
c.SetResponseHeader("Cache-Control", "no-store")
|
||||
}
|
||||
return c.Created("ok")
|
||||
}))
|
||||
if w := invoke(h, "POST", "123456789", "alice", "key"); w.Code != 413 || calls != 0 {
|
||||
t.Fatal(w.Code, calls)
|
||||
}
|
||||
first := invoke(h, "POST", "{}", "alice", "key")
|
||||
if mode == "panic" || mode == "error" || mode == "overflow" {
|
||||
if first.Code != 500 {
|
||||
t.Fatal(first.Code)
|
||||
}
|
||||
} else if first.Code != 201 {
|
||||
t.Fatal(first.Code)
|
||||
}
|
||||
if w := invoke(h, "POST", "{}", "alice", "key"); w.Code != 409 || calls != 1 {
|
||||
t.Fatal(w.Code, calls)
|
||||
}
|
||||
if w := invoke(h, "POST", "{}", "alice", "newKey"); w.Code != 503 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyInFlightLimitAndTimeoutComposition(t *testing.T) {
|
||||
i, _ := NewIdempotency(IdempotencyConfig{Scope: identity, MaxInFlight: 1})
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
h := i.Middleware()(func(c *httpx.Context) error { close(started); <-release; return c.NoContent() })
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); invoke(h, "POST", "{}", "alice", "one") }()
|
||||
<-started
|
||||
if w := invoke(h, "POST", "{}", "alice", "two"); w.Code != 503 {
|
||||
t.Error(w.Code)
|
||||
}
|
||||
close(release)
|
||||
<-done
|
||||
timed := Timeout(5 * time.Millisecond)(i.Middleware()(func(c *httpx.Context) error { <-c.Context().Done(); return context.DeadlineExceeded }))
|
||||
if w := invoke(timed, "POST", "{}", "alice", "timeout"); w.Code != 504 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if w := invoke(timed, "POST", "{}", "alice", "timeout"); w.Code != 409 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
}
|
||||
109
middleware/rateLimit.go
Normal file
109
middleware/rateLimit.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Rate float64 `json:"rate"` // Tokens per second, per key.
|
||||
Burst int `json:"burst"`
|
||||
MaxKeys int `json:"maxKeys"`
|
||||
IdleTTL time.Duration `json:"idleTtl"`
|
||||
Key func(*httpx.Context) string `json:"-"` // Default: trusted ClientIP.
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
key string
|
||||
tokens float64
|
||||
updated time.Time
|
||||
}
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
config RateLimitConfig
|
||||
items map[string]*list.Element
|
||||
lru *list.List
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRateLimiter(cfg RateLimitConfig) (*RateLimiter, error) {
|
||||
if math.IsNaN(cfg.Rate) || math.IsInf(cfg.Rate, 0) || cfg.Rate <= 0 || cfg.Burst < 1 || cfg.MaxKeys < 0 || cfg.IdleTTL < 0 {
|
||||
return nil, errors.New("middleware: invalid rate limit configuration")
|
||||
}
|
||||
refill := float64(cfg.Burst) / cfg.Rate
|
||||
if refill > (24 * time.Hour).Seconds() {
|
||||
return nil, errors.New("middleware: bucket refill time must not exceed 24h")
|
||||
}
|
||||
minimum := time.Duration(math.Ceil(refill * float64(time.Second)))
|
||||
if cfg.IdleTTL == 0 {
|
||||
cfg.IdleTTL = max(5*time.Minute, minimum)
|
||||
}
|
||||
if cfg.IdleTTL < minimum {
|
||||
return nil, errors.New("middleware: idleTtl must allow a full bucket refill")
|
||||
}
|
||||
if cfg.MaxKeys == 0 {
|
||||
cfg.MaxKeys = 10000
|
||||
}
|
||||
if cfg.Key == nil {
|
||||
cfg.Key = func(c *httpx.Context) string { return c.ClientIP() }
|
||||
}
|
||||
return &RateLimiter{config: cfg, items: make(map[string]*list.Element), lru: list.New(), now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (l *RateLimiter) Middleware() httpx.Middleware {
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
key := l.config.Key(c)
|
||||
if key == "" || len(key) > 1024 {
|
||||
return apierrors.BadRequest("INVALID_RATE_KEY", "invalid rate limit key")
|
||||
}
|
||||
delay, full := l.allow(key)
|
||||
if full {
|
||||
return apierrors.New(503, "RATE_LIMIT_CAPACITY", "rate limiter capacity reached")
|
||||
}
|
||||
if delay > 0 {
|
||||
c.SetResponseHeader("Retry-After", strconv.Itoa(max(1, int(math.Ceil(delay.Seconds())))))
|
||||
return apierrors.New(429, "RATE_LIMITED", "too many requests")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RateLimiter) allow(key string) (time.Duration, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
e := l.items[key]
|
||||
if e == nil {
|
||||
for l.lru.Len() > 0 {
|
||||
old := l.lru.Back()
|
||||
if now.Sub(old.Value.(*bucket).updated) < l.config.IdleTTL {
|
||||
break
|
||||
}
|
||||
delete(l.items, old.Value.(*bucket).key)
|
||||
l.lru.Remove(old)
|
||||
}
|
||||
if len(l.items) >= l.config.MaxKeys {
|
||||
return 0, true
|
||||
}
|
||||
e = l.lru.PushFront(&bucket{key: key, tokens: float64(l.config.Burst), updated: now})
|
||||
l.items[key] = e
|
||||
}
|
||||
b := e.Value.(*bucket)
|
||||
b.tokens = min(float64(l.config.Burst), b.tokens+max(0, now.Sub(b.updated).Seconds())*l.config.Rate)
|
||||
b.updated = now
|
||||
l.lru.MoveToFront(e)
|
||||
if b.tokens >= 1 {
|
||||
b.tokens--
|
||||
return 0, false
|
||||
}
|
||||
return time.Duration(math.Ceil((1 - b.tokens) / l.config.Rate * float64(time.Second))), false
|
||||
}
|
||||
35
middleware/timeout.go
Normal file
35
middleware/timeout.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
// Timeout applies a cooperative deadline to handlers and downstream calls.
|
||||
// It does not spawn a goroutine or race a handler's response. Business code must
|
||||
// observe Context().Done(); an already committed response cannot be replaced.
|
||||
func Timeout(duration time.Duration) httpx.Middleware {
|
||||
if duration <= 0 {
|
||||
panic("middleware: timeout must be positive")
|
||||
}
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
parent := c.Context()
|
||||
ctx, cancel := context.WithTimeout(parent, duration)
|
||||
defer cancel()
|
||||
c.WithContext(ctx)
|
||||
defer c.WithContext(parent)
|
||||
if ctx.Err() != nil {
|
||||
return apierrors.New(504, "REQUEST_TIMEOUT", "request deadline exceeded")
|
||||
}
|
||||
err := next(c)
|
||||
if ctx.Err() == context.DeadlineExceeded && !c.Responded() {
|
||||
return apierrors.New(504, "REQUEST_TIMEOUT", "request deadline exceeded").WithCause(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
17
omni.go
17
omni.go
@@ -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)
|
||||
}
|
||||
|
||||
80
performance/http2FlowControlLinux.json
Normal file
80
performance/http2FlowControlLinux.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"testedCommit": "fa2be5e0166f1fa581a2b0c7ee2fc29c44ac2676",
|
||||
"startedAt": "2026-09-23T02:50:32.502669+00:00",
|
||||
"imageId": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"command": [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-boundary-363c306dd46d",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"-v",
|
||||
"/root/go-omni-validation:/workspace:ro",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go test -race -count=1 -timeout 2m -v ./transport/http2 && go vet ./transport/http2 && go build ./transport/http2"
|
||||
],
|
||||
"wallTimeoutSeconds": 300,
|
||||
"finishedAt": "2026-09-23T02:51:28.854252+00:00",
|
||||
"exitCode": 0,
|
||||
"logSha256": "52c96133d72ac15fa13b5784ba045a732d5ed0cf728f111481f0046407efff81",
|
||||
"platform": "linux/amd64",
|
||||
"goVersion": "go1.25.14",
|
||||
"package": "./transport/http2",
|
||||
"checks": [
|
||||
"race",
|
||||
"vet",
|
||||
"build"
|
||||
],
|
||||
"passedTests": [
|
||||
"TestTLSConnectionLimitRecoversAfterHandshakeTimeout",
|
||||
"TestSlowResponseStreamIsolationAndRecovery",
|
||||
"TestConnectionWindowIsolationAndRecovery",
|
||||
"TestShutdownReleasesFlowControlledWriter",
|
||||
"TestShutdownSendsGoAwayAndDrains",
|
||||
"TestRSTStreamCancelsOnlyOneRequest",
|
||||
"TestCanceledServeContextWaitsForShutdownDeadline",
|
||||
"TestTLSMixedSoak",
|
||||
"TestServerHandlesH2CRequest",
|
||||
"TestServerEnforcesGlobalRequestLimitAndRecovers",
|
||||
"TestServerRejectsBodyAboveLimitAndRecovers",
|
||||
"TestServerRejectsHeadersAboveLimitAndRecovers",
|
||||
"TestServerMultiplexes128Streams",
|
||||
"TestServerStreamsLargeBodyWithFlowControl",
|
||||
"TestServerTLSUsesH2ALPN",
|
||||
"TestTLSSoak"
|
||||
],
|
||||
"notes": [
|
||||
"Sequentially run after the 30-minute mixed soak; limited to 2 CPUs, 2048 MiB RAM with no swap, and 256 tasks.",
|
||||
"The working tree was clean at testedCommit and mounted read-only. The supervisor enforced a 300-second wall timeout and removed only its own container.",
|
||||
"Malformed-frame and header-limit tests deliberately trigger HTTP/2 error logs; each corresponding test passed.",
|
||||
"Race-detector memory in this regression log is not a production memory baseline. Ordinary-mode mixed-soak measurements are recorded separately."
|
||||
],
|
||||
"postRunCheck": {
|
||||
"checkedAt": "2026-09-23T02:53:33.308077+00:00",
|
||||
"preExistingContainersStillRunning": 7,
|
||||
"remainingHTTP2ValidationContainers": []
|
||||
}
|
||||
}
|
||||
53
performance/http2FlowControlLinux.txt
Normal file
53
performance/http2FlowControlLinux.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestTLSConnectionLimitRecoversAfterHandshakeTimeout
|
||||
--- PASS: TestTLSConnectionLimitRecoversAfterHandshakeTimeout (1.12s)
|
||||
=== RUN TestSlowResponseStreamIsolationAndRecovery
|
||||
--- PASS: TestSlowResponseStreamIsolationAndRecovery (0.00s)
|
||||
=== RUN TestConnectionWindowIsolationAndRecovery
|
||||
--- PASS: TestConnectionWindowIsolationAndRecovery (0.00s)
|
||||
=== RUN TestShutdownReleasesFlowControlledWriter
|
||||
--- PASS: TestShutdownReleasesFlowControlledWriter (0.10s)
|
||||
=== RUN TestShutdownSendsGoAwayAndDrains
|
||||
--- PASS: TestShutdownSendsGoAwayAndDrains (0.00s)
|
||||
=== RUN TestRSTStreamCancelsOnlyOneRequest
|
||||
--- PASS: TestRSTStreamCancelsOnlyOneRequest (0.00s)
|
||||
=== RUN TestCanceledServeContextWaitsForShutdownDeadline
|
||||
--- PASS: TestCanceledServeContextWaitsForShutdownDeadline (0.10s)
|
||||
=== RUN TestTLSMixedSoak
|
||||
mixedSoak_test.go:37: phase=before elapsed=1ms requests=0 failures=0 heapBytes=2409792 rssBytes=37199872 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:0 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
mixedSoak_test.go:38: scenario=mixed connections=8 workersPerConnection=16 uploadBytes=262144 uploadChunkBytes=4096 uploadDelay=2ms downloadBytes=8388608 downloadChunkBytes=65536 downloadDelay=20ms
|
||||
mixedSoak_test.go:190: phase=after elapsed=5.655s requests=10489 failures=0 heapBytes=62134360 rssBytes=470847488 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:4 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
mixedSoak_test.go:124: mixedTotals phase=after health=10356 slowUploads=117 slowDownloads=16 uploadedBytes=30670848 downloadedBytes=134217728 serverFailures=0
|
||||
mixedSoak_test.go:196: connection=0 health=1318 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=1 health=1228 slowUploads=14 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=2 health=1194 slowUploads=14 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=3 health=1322 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=4 health=1331 slowUploads=14 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=5 health=1342 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=6 health=1321 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:196: connection=7 health=1300 slowUploads=15 slowDownloads=2
|
||||
mixedSoak_test.go:209: phase=afterReclaim elapsed=5.661s requests=10489 failures=0 heapBytes=440880 rssBytes=468951040 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:4 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestTLSMixedSoak (5.66s)
|
||||
=== RUN TestServerHandlesH2CRequest
|
||||
--- PASS: TestServerHandlesH2CRequest (0.00s)
|
||||
=== RUN TestServerEnforcesGlobalRequestLimitAndRecovers
|
||||
--- PASS: TestServerEnforcesGlobalRequestLimitAndRecovers (0.00s)
|
||||
=== RUN TestServerRejectsBodyAboveLimitAndRecovers
|
||||
--- PASS: TestServerRejectsBodyAboveLimitAndRecovers (0.00s)
|
||||
=== RUN TestServerRejectsHeadersAboveLimitAndRecovers
|
||||
2026/09/23 02:51:04 http2: server connection error from 127.0.0.1:36276: connection error: COMPRESSION_ERROR
|
||||
--- PASS: TestServerRejectsHeadersAboveLimitAndRecovers (1.00s)
|
||||
=== RUN TestServerMultiplexes128Streams
|
||||
--- PASS: TestServerMultiplexes128Streams (0.02s)
|
||||
=== RUN TestServerStreamsLargeBodyWithFlowControl
|
||||
--- PASS: TestServerStreamsLargeBodyWithFlowControl (0.16s)
|
||||
=== RUN TestServerTLSUsesH2ALPN
|
||||
--- PASS: TestServerTLSUsesH2ALPN (0.14s)
|
||||
=== RUN TestTLSSoak
|
||||
soak_test.go:26: phase=before elapsed=0s requests=0 failures=0 heapBytes=1497944 rssBytes=391569408 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:0 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
soak_test.go:159: phase=after elapsed=3.122s requests=18182 failures=0 heapBytes=4739552 rssBytes=238292992 goroutines=3 fileDescriptors=6 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestTLSSoak (3.12s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.478s
|
||||
3681
performance/http2MixedSoakLinux.json
Normal file
3681
performance/http2MixedSoakLinux.json
Normal file
File diff suppressed because it is too large
Load Diff
265
performance/http2MixedSoakLinux.txt
Normal file
265
performance/http2MixedSoakLinux.txt
Normal file
@@ -0,0 +1,265 @@
|
||||
HTTP/2 Linux soak image: golang:1.25
|
||||
HTTP/2 TLS soak duration: 30m
|
||||
HTTP/2 TLS soak mode: regular
|
||||
HTTP/2 TLS soak scenario: mixed
|
||||
HTTP/2 soak container: go-omni-http2-soak-fa422c57dfa1; limits: 2 CPUs, 2048 MiB, 256 tasks
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestTLSMixedSoak
|
||||
mixedSoak_test.go:37: phase=before elapsed=0s requests=0 failures=0 heapBytes=170136 rssBytes=8613888 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:0 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
mixedSoak_test.go:38: scenario=mixed connections=8 workersPerConnection=16 uploadBytes=262144 uploadChunkBytes=4096 uploadDelay=2ms downloadBytes=8388608 downloadChunkBytes=65536 downloadDelay=20ms
|
||||
mixedSoak_test.go:135: phase=running elapsed=15.143s requests=444111 failures=0 heapBytes=41226328 rssBytes=100597760 goroutines=359 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=443187 slowUploads=884 slowDownloads=40 uploadedBytes=231735296 downloadedBytes=335544320 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=30.143s requests=886889 failures=0 heapBytes=76939344 rssBytes=119885824 goroutines=351 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=885025 slowUploads=1776 slowDownloads=88 uploadedBytes=465567744 downloadedBytes=738197504 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=45.143s requests=1327236 failures=0 heapBytes=98442464 rssBytes=119926784 goroutines=348 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=1324437 slowUploads=2663 slowDownloads=136 uploadedBytes=698089472 downloadedBytes=1140850688 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=1m0.143s requests=1768857 failures=0 heapBytes=99328992 rssBytes=119402496 goroutines=359 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=1765116 slowUploads=3557 slowDownloads=184 uploadedBytes=932446208 downloadedBytes=1543503872 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=1m15.143s requests=2211456 failures=0 heapBytes=60732216 rssBytes=90337280 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=2206781 slowUploads=4445 slowDownloads=232 uploadedBytes=1165230080 downloadedBytes=1946157056 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=1m30.143s requests=2652108 failures=0 heapBytes=53063064 rssBytes=85688320 goroutines=350 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=2646501 slowUploads=5335 slowDownloads=272 uploadedBytes=1398538240 downloadedBytes=2281701376 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=1m45.143s requests=3094082 failures=0 heapBytes=81458456 rssBytes=117563392 goroutines=343 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=3087534 slowUploads=6228 slowDownloads=320 uploadedBytes=1632632832 downloadedBytes=2684354560 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=2m0.143s requests=3532884 failures=0 heapBytes=86878776 rssBytes=119468032 goroutines=292 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=3525398 slowUploads=7118 slowDownloads=368 uploadedBytes=1865940992 downloadedBytes=3087007744 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=2m15.143s requests=3974010 failures=0 heapBytes=98205016 rssBytes=119029760 goroutines=313 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=3965593 slowUploads=8008 slowDownloads=416 uploadedBytes=2099249152 downloadedBytes=3489660928 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=2m30.143s requests=4415820 failures=0 heapBytes=90842632 rssBytes=109375488 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=4406456 slowUploads=8900 slowDownloads=464 uploadedBytes=2333081600 downloadedBytes=3892314112 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=2m45.143s requests=4859795 failures=0 heapBytes=46212528 rssBytes=75120640 goroutines=382 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=4849501 slowUploads=9790 slowDownloads=504 uploadedBytes=2566389760 downloadedBytes=4227858432 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=3m0.143s requests=5302557 failures=0 heapBytes=59378560 rssBytes=103960576 goroutines=323 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=5291324 slowUploads=10681 slowDownloads=552 uploadedBytes=2799960064 downloadedBytes=4630511616 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=3m15.143s requests=5745355 failures=0 heapBytes=65581312 rssBytes=120012800 goroutines=326 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=5733185 slowUploads=11571 slowDownloads=600 uploadedBytes=3033268224 downloadedBytes=5033164800 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=3m30.143s requests=6184725 failures=0 heapBytes=83765136 rssBytes=119152640 goroutines=365 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=6171616 slowUploads=12461 slowDownloads=648 uploadedBytes=3266576384 downloadedBytes=5435817984 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=3m45.143s requests=6627613 failures=0 heapBytes=83719272 rssBytes=119508992 goroutines=326 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=6613566 slowUploads=13352 slowDownloads=696 uploadedBytes=3500146688 downloadedBytes=5838471168 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=4m0.143s requests=7072007 failures=0 heapBytes=40007456 rssBytes=83099648 goroutines=366 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=7057021 slowUploads=14246 slowDownloads=740 uploadedBytes=3734503424 downloadedBytes=6207569920 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=4m15.143s requests=7515397 failures=0 heapBytes=52621088 rssBytes=88940544 goroutines=320 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=7499478 slowUploads=15136 slowDownloads=784 uploadedBytes=3967811584 downloadedBytes=6576668672 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=4m30.143s requests=7957026 failures=0 heapBytes=49206800 rssBytes=115929088 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=7940168 slowUploads=16026 slowDownloads=832 uploadedBytes=4201119744 downloadedBytes=6979321856 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=4m45.143s requests=8395015 failures=0 heapBytes=57830768 rssBytes=120049664 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:13}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=8377223 slowUploads=16918 slowDownloads=880 uploadedBytes=4434952192 downloadedBytes=7381975040 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=5m0.143s requests=8838797 failures=0 heapBytes=66531296 rssBytes=120057856 goroutines=364 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=8820061 slowUploads=17808 slowDownloads=928 uploadedBytes=4668260352 downloadedBytes=7784628224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=5m15.143s requests=9279778 failures=0 heapBytes=60693440 rssBytes=103329792 goroutines=361 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=9260102 slowUploads=18700 slowDownloads=976 uploadedBytes=4902092800 downloadedBytes=8187281408 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=5m30.143s requests=9726070 failures=0 heapBytes=29946072 rssBytes=75563008 goroutines=288 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=9705463 slowUploads=19591 slowDownloads=1016 uploadedBytes=5135663104 downloadedBytes=8522825728 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=5m45.144s requests=10170774 failures=0 heapBytes=41152368 rssBytes=106692608 goroutines=360 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=10149227 slowUploads=20483 slowDownloads=1064 uploadedBytes=5369495552 downloadedBytes=8925478912 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=6m0.143s requests=10614632 failures=0 heapBytes=80360752 rssBytes=120602624 goroutines=325 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=10592151 slowUploads=21373 slowDownloads=1112 uploadedBytes=5602803712 downloadedBytes=9328132096 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=6m15.143s requests=11059452 failures=0 heapBytes=87305144 rssBytes=120602624 goroutines=324 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=11036026 slowUploads=22266 slowDownloads=1160 uploadedBytes=5836898304 downloadedBytes=9730785280 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=6m30.143s requests=11499123 failures=0 heapBytes=95978912 rssBytes=120606720 goroutines=365 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=11474760 slowUploads=23155 slowDownloads=1208 uploadedBytes=6069944320 downloadedBytes=10133438464 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=6m45.143s requests=11944358 failures=0 heapBytes=56042736 rssBytes=75292672 goroutines=366 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=11919058 slowUploads=24048 slowDownloads=1252 uploadedBytes=6304038912 downloadedBytes=10502537216 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=7m0.143s requests=12386374 failures=0 heapBytes=40072440 rssBytes=94666752 goroutines=354 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=12360140 slowUploads=24938 slowDownloads=1296 uploadedBytes=6537347072 downloadedBytes=10871635968 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=7m15.143s requests=12830700 failures=0 heapBytes=62440144 rssBytes=120365056 goroutines=328 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=12803533 slowUploads=25829 slowDownloads=1344 uploadedBytes=6770917376 downloadedBytes=11274289152 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=7m30.143s requests=13273492 failures=0 heapBytes=67101096 rssBytes=121159680 goroutines=334 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:13}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=13245381 slowUploads=26719 slowDownloads=1392 uploadedBytes=7004225536 downloadedBytes=11676942336 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=7m45.143s requests=13715931 failures=0 heapBytes=78929632 rssBytes=120643584 goroutines=361 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=13686880 slowUploads=27611 slowDownloads=1440 uploadedBytes=7238057984 downloadedBytes=12079595520 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=8m0.143s requests=14158538 failures=0 heapBytes=83472520 rssBytes=102039552 goroutines=342 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=14128556 slowUploads=28500 slowDownloads=1488 uploadedBytes=7471104000 downloadedBytes=12482248704 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=8m15.143s requests=14606106 failures=0 heapBytes=36808416 rssBytes=77897728 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=14575184 slowUploads=29394 slowDownloads=1528 uploadedBytes=7705460736 downloadedBytes=12817793024 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=8m30.143s requests=15050580 failures=0 heapBytes=51099992 rssBytes=106217472 goroutines=315 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=15018720 slowUploads=30284 slowDownloads=1576 uploadedBytes=7938768896 downloadedBytes=13220446208 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=8m45.143s requests=15492752 failures=0 heapBytes=99120736 rssBytes=121679872 goroutines=326 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=15459953 slowUploads=31175 slowDownloads=1624 uploadedBytes=8172339200 downloadedBytes=13623099392 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=9m0.143s requests=15937860 failures=0 heapBytes=95959552 rssBytes=121716736 goroutines=344 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=15904122 slowUploads=32066 slowDownloads=1672 uploadedBytes=8405909504 downloadedBytes=14025752576 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=9m15.143s requests=16379592 failures=0 heapBytes=54420112 rssBytes=121249792 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=16344914 slowUploads=32958 slowDownloads=1720 uploadedBytes=8639741952 downloadedBytes=14428405760 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=9m30.143s requests=16825611 failures=0 heapBytes=62855872 rssBytes=81920000 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=16790004 slowUploads=33848 slowDownloads=1764 uploadedBytes=8873050112 downloadedBytes=14797504512 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=9m45.144s requests=17273037 failures=0 heapBytes=56223560 rssBytes=98967552 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=17236495 slowUploads=34739 slowDownloads=1808 uploadedBytes=9106620416 downloadedBytes=15166603264 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=10m0.143s requests=17718289 failures=0 heapBytes=90363368 rssBytes=121196544 goroutines=312 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=17680804 slowUploads=35632 slowDownloads=1856 uploadedBytes=9340715008 downloadedBytes=15569256448 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=10m15.143s requests=18164393 failures=0 heapBytes=91620912 rssBytes=121708544 goroutines=328 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:14}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=18125968 slowUploads=36521 slowDownloads=1904 uploadedBytes=9573761024 downloadedBytes=15971909632 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=10m30.143s requests=18604920 failures=0 heapBytes=85189216 rssBytes=119672832 goroutines=397 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=18565557 slowUploads=37412 slowDownloads=1952 uploadedBytes=9807331328 downloadedBytes=16374562816 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=10m45.143s requests=19049168 failures=0 heapBytes=71446040 rssBytes=97730560 goroutines=371 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=19008872 slowUploads=38304 slowDownloads=2000 uploadedBytes=10041163776 downloadedBytes=16777216000 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=11m0.143s requests=19496908 failures=0 heapBytes=35314648 rssBytes=81412096 goroutines=333 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:6}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=19455674 slowUploads=39195 slowDownloads=2040 uploadedBytes=10274734080 downloadedBytes=17112760320 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=11m15.143s requests=19941947 failures=0 heapBytes=52063200 rssBytes=112910336 goroutines=353 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=19899771 slowUploads=40088 slowDownloads=2088 uploadedBytes=10508828672 downloadedBytes=17515413504 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=11m30.143s requests=20387835 failures=0 heapBytes=91179064 rssBytes=121262080 goroutines=337 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=20344721 slowUploads=40978 slowDownloads=2136 uploadedBytes=10742136832 downloadedBytes=17918066688 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=11m45.143s requests=20834104 failures=0 heapBytes=96727232 rssBytes=121204736 goroutines=373 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=20790049 slowUploads=41871 slowDownloads=2184 uploadedBytes=10976231424 downloadedBytes=18320719872 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=12m0.143s requests=21274032 failures=0 heapBytes=73363000 rssBytes=121729024 goroutines=395 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=21229039 slowUploads=42761 slowDownloads=2232 uploadedBytes=11209539584 downloadedBytes=18723373056 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=12m15.143s requests=21717175 failures=0 heapBytes=55163016 rssBytes=80625664 goroutines=345 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=21671245 slowUploads=43654 slowDownloads=2276 uploadedBytes=11443634176 downloadedBytes=19092471808 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=12m30.143s requests=22165567 failures=0 heapBytes=58932064 rssBytes=100704256 goroutines=332 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=22118701 slowUploads=44546 slowDownloads=2320 uploadedBytes=11677466624 downloadedBytes=19461570560 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=12m45.143s requests=22605128 failures=0 heapBytes=65821152 rssBytes=121774080 goroutines=295 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=22557331 slowUploads=45436 slowDownloads=2368 uploadedBytes=11910774784 downloadedBytes=19864223744 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=13m0.143s requests=23048491 failures=0 heapBytes=71758864 rssBytes=121741312 goroutines=346 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:14}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=22999748 slowUploads=46327 slowDownloads=2416 uploadedBytes=12144345088 downloadedBytes=20266876928 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=13m15.143s requests=23490751 failures=0 heapBytes=93734424 rssBytes=121221120 goroutines=360 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=23441069 slowUploads=47219 slowDownloads=2464 uploadedBytes=12378177536 downloadedBytes=20669530112 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=13m30.143s requests=23937415 failures=0 heapBytes=67599608 rssBytes=86278144 goroutines=361 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=23886795 slowUploads=48109 slowDownloads=2511 uploadedBytes=12611485696 downloadedBytes=21063794688 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=13m45.143s requests=24380375 failures=0 heapBytes=39698288 rssBytes=80867328 goroutines=299 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=24328828 slowUploads=49001 slowDownloads=2553 uploadedBytes=12845318144 downloadedBytes=21416116224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=14m0.143s requests=24821991 failures=0 heapBytes=56792672 rssBytes=110620672 goroutines=354 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=24769501 slowUploads=49890 slowDownloads=2600 uploadedBytes=13078364160 downloadedBytes=21810380800 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=14m15.143s requests=25267435 failures=0 heapBytes=59737600 rssBytes=121171968 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=25214005 slowUploads=50784 slowDownloads=2648 uploadedBytes=13312720896 downloadedBytes=22213033984 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=14m30.143s requests=25712957 failures=0 heapBytes=91392208 rssBytes=119664640 goroutines=341 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=25658586 slowUploads=51675 slowDownloads=2696 uploadedBytes=13546291200 downloadedBytes=22615687168 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=14m45.143s requests=26155555 failures=0 heapBytes=57260256 rssBytes=109522944 goroutines=359 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=26100248 slowUploads=52568 slowDownloads=2744 uploadedBytes=13780385792 downloadedBytes=23018340352 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=15m0.143s requests=26597733 failures=0 heapBytes=40190080 rssBytes=81768448 goroutines=359 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=26541487 slowUploads=53458 slowDownloads=2788 uploadedBytes=14013693952 downloadedBytes=23387439104 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=15m15.143s requests=27037735 failures=0 heapBytes=66516128 rssBytes=100077568 goroutines=362 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=26980556 slowUploads=54350 slowDownloads=2832 uploadedBytes=14247526400 downloadedBytes=23756537856 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=15m30.143s requests=27476076 failures=0 heapBytes=60893368 rssBytes=121782272 goroutines=333 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=27417961 slowUploads=55241 slowDownloads=2880 uploadedBytes=14481096704 downloadedBytes=24159191040 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=15m45.143s requests=27919500 failures=0 heapBytes=88112872 rssBytes=121765888 goroutines=349 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:14}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=27860441 slowUploads=56131 slowDownloads=2928 uploadedBytes=14714404864 downloadedBytes=24561844224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=16m0.143s requests=28363283 failures=0 heapBytes=76293576 rssBytes=121774080 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=28303284 slowUploads=57023 slowDownloads=2976 uploadedBytes=14948237312 downloadedBytes=24964497408 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=16m15.143s requests=28803143 failures=0 heapBytes=50553176 rssBytes=94076928 goroutines=335 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=28742207 slowUploads=57913 slowDownloads=3023 uploadedBytes=15181545472 downloadedBytes=25358761984 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=16m30.143s requests=29248721 failures=0 heapBytes=50037296 rssBytes=88997888 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=29186854 slowUploads=58806 slowDownloads=3064 uploadedBytes=15415640064 downloadedBytes=25702694912 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=16m45.143s requests=29691598 failures=0 heapBytes=56490576 rssBytes=112680960 goroutines=339 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=29628794 slowUploads=59696 slowDownloads=3112 uploadedBytes=15648948224 downloadedBytes=26105348096 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=17m0.143s requests=30135271 failures=0 heapBytes=101057680 rssBytes=121397248 goroutines=350 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=30071522 slowUploads=60589 slowDownloads=3160 uploadedBytes=15883042816 downloadedBytes=26508001280 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=17m15.143s requests=30578566 failures=0 heapBytes=102154656 rssBytes=121921536 goroutines=361 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:27}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=30513878 slowUploads=61480 slowDownloads=3208 uploadedBytes=16116613120 downloadedBytes=26910654464 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=17m30.143s requests=31020857 failures=0 heapBytes=86815776 rssBytes=105476096 goroutines=323 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=30955229 slowUploads=62372 slowDownloads=3256 uploadedBytes=16350445568 downloadedBytes=27313307648 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=17m45.143s requests=31459944 failures=0 heapBytes=51699872 rssBytes=79634432 goroutines=310 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=31393379 slowUploads=63265 slowDownloads=3300 uploadedBytes=16584540160 downloadedBytes=27682406400 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=18m0.143s requests=31903673 failures=0 heapBytes=77125768 rssBytes=103821312 goroutines=368 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=31836174 slowUploads=64155 slowDownloads=3344 uploadedBytes=16817848320 downloadedBytes=28051505152 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=18m15.143s requests=32347088 failures=0 heapBytes=87810928 rssBytes=121810944 goroutines=354 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=32278650 slowUploads=65047 slowDownloads=3392 uploadedBytes=17051680768 downloadedBytes=28454158336 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=18m30.143s requests=32791958 failures=0 heapBytes=83378912 rssBytes=121843712 goroutines=368 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=32722582 slowUploads=65940 slowDownloads=3440 uploadedBytes=17285775360 downloadedBytes=28856811520 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=18m45.143s requests=33238456 failures=0 heapBytes=73365344 rssBytes=121778176 goroutines=310 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=33168137 slowUploads=66831 slowDownloads=3488 uploadedBytes=17519345664 downloadedBytes=29259464704 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=19m0.143s requests=33678524 failures=0 heapBytes=73470384 rssBytes=96964608 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:13}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=33607268 slowUploads=67723 slowDownloads=3533 uploadedBytes=17753178112 downloadedBytes=29636952064 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=19m15.143s requests=34124154 failures=0 heapBytes=44371688 rssBytes=86138880 goroutines=306 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=34051968 slowUploads=68615 slowDownloads=3576 uploadedBytes=17987010560 downloadedBytes=29997662208 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=19m30.143s requests=34567776 failures=0 heapBytes=69339968 rssBytes=116219904 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=34494646 slowUploads=69506 slowDownloads=3624 uploadedBytes=18220580864 downloadedBytes=30400315392 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=19m45.143s requests=35012899 failures=0 heapBytes=77088312 rssBytes=122314752 goroutines=333 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=34938829 slowUploads=70399 slowDownloads=3672 uploadedBytes=18454675456 downloadedBytes=30802968576 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=20m0.143s requests=35459295 failures=0 heapBytes=71491968 rssBytes=121794560 goroutines=346 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=35384287 slowUploads=71292 slowDownloads=3720 uploadedBytes=18688770048 downloadedBytes=31205621760 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=20m15.143s requests=35904737 failures=0 heapBytes=71675952 rssBytes=106598400 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=35828787 slowUploads=72182 slowDownloads=3768 uploadedBytes=18922078208 downloadedBytes=31608274944 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=20m30.143s requests=36350214 failures=0 heapBytes=50574208 rssBytes=84537344 goroutines=306 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:10}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=36273329 slowUploads=73074 slowDownloads=3811 uploadedBytes=19155910656 downloadedBytes=31968985088 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=20m45.143s requests=36794194 failures=0 heapBytes=52755624 rssBytes=104304640 goroutines=347 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=36716374 slowUploads=73964 slowDownloads=3856 uploadedBytes=19389218816 downloadedBytes=32346472448 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=21m0.143s requests=37237267 failures=0 heapBytes=90759440 rssBytes=121802752 goroutines=285 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=37158506 slowUploads=74857 slowDownloads=3904 uploadedBytes=19623313408 downloadedBytes=32749125632 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=21m15.143s requests=37682135 failures=0 heapBytes=75071160 rssBytes=121802752 goroutines=332 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=37602436 slowUploads=75747 slowDownloads=3952 uploadedBytes=19856621568 downloadedBytes=33151778816 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=21m30.143s requests=38125279 failures=0 heapBytes=84651016 rssBytes=117342208 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=38044640 slowUploads=76639 slowDownloads=4000 uploadedBytes=20090454016 downloadedBytes=33554432000 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=21m45.143s requests=38569220 failures=0 heapBytes=70593904 rssBytes=97050624 goroutines=373 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=38487645 slowUploads=77531 slowDownloads=4044 uploadedBytes=20324286464 downloadedBytes=33923530752 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=22m0.143s requests=39016925 failures=0 heapBytes=59654016 rssBytes=93556736 goroutines=325 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=38934413 slowUploads=78424 slowDownloads=4088 uploadedBytes=20558381056 downloadedBytes=34292629504 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=22m15.143s requests=39463910 failures=0 heapBytes=53261856 rssBytes=114139136 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=39380459 slowUploads=79315 slowDownloads=4136 uploadedBytes=20791951360 downloadedBytes=34695282688 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=22m30.143s requests=39910237 failures=0 heapBytes=62860672 rssBytes=121815040 goroutines=366 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=39825849 slowUploads=80209 slowDownloads=4184 uploadedBytes=21026308096 downloadedBytes=35097935872 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=22m45.143s requests=40360417 failures=0 heapBytes=70322976 rssBytes=121286656 goroutines=368 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=40275086 slowUploads=81099 slowDownloads=4232 uploadedBytes=21259616256 downloadedBytes=35500589056 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=23m0.143s requests=40801788 failures=0 heapBytes=76912600 rssBytes=97243136 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=40715518 slowUploads=81990 slowDownloads=4280 uploadedBytes=21493186560 downloadedBytes=35903242240 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=23m15.143s requests=41246645 failures=0 heapBytes=54950248 rssBytes=84979712 goroutines=337 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=41159438 slowUploads=82883 slowDownloads=4324 uploadedBytes=21727281152 downloadedBytes=36272340992 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=23m30.143s requests=41687236 failures=0 heapBytes=43818576 rssBytes=101183488 goroutines=321 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=41599097 slowUploads=83774 slowDownloads=4368 uploadedBytes=21960851456 downloadedBytes=36641439744 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=23m45.143s requests=42128546 failures=0 heapBytes=54495128 rssBytes=121827328 goroutines=288 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=42039470 slowUploads=84667 slowDownloads=4416 uploadedBytes=22194946048 downloadedBytes=37044092928 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=24m0.143s requests=42569443 failures=0 heapBytes=98238024 rssBytes=119263232 goroutines=362 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=42479426 slowUploads=85556 slowDownloads=4464 uploadedBytes=22427992064 downloadedBytes=37446746112 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=24m15.143s requests=43006434 failures=0 heapBytes=61451176 rssBytes=115818496 goroutines=330 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=42915474 slowUploads=86448 slowDownloads=4512 uploadedBytes=22661824512 downloadedBytes=37849399296 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=24m30.143s requests=43446211 failures=0 heapBytes=53221152 rssBytes=96935936 goroutines=294 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=43354315 slowUploads=87339 slowDownloads=4557 uploadedBytes=22895394816 downloadedBytes=38226886656 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=24m45.143s requests=43888201 failures=0 heapBytes=41094952 rssBytes=88412160 goroutines=306 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=43795377 slowUploads=88230 slowDownloads=4600 uploadedBytes=23128965120 downloadedBytes=38587596800 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=25m0.143s requests=44331683 failures=0 heapBytes=74520696 rssBytes=117968896 goroutines=360 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=44237912 slowUploads=89123 slowDownloads=4648 uploadedBytes=23363059712 downloadedBytes=38990249984 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=25m15.143s requests=44771023 failures=0 heapBytes=61522184 rssBytes=121311232 goroutines=314 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=44676311 slowUploads=90016 slowDownloads=4696 uploadedBytes=23597154304 downloadedBytes=39392903168 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=25m30.143s requests=45210979 failures=0 heapBytes=71823520 rssBytes=121315328 goroutines=364 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=45115328 slowUploads=90908 slowDownloads=4744 uploadedBytes=23830986752 downloadedBytes=39795556352 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=25m45.143s requests=45655488 failures=0 heapBytes=73294312 rssBytes=98951168 goroutines=352 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=45558899 slowUploads=91798 slowDownloads=4791 uploadedBytes=24064294912 downloadedBytes=40189820928 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=26m0.143s requests=46098111 failures=0 heapBytes=61891384 rssBytes=85794816 goroutines=331 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:11}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=46000586 slowUploads=92690 slowDownloads=4835 uploadedBytes=24298127360 downloadedBytes=40558919680 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=26m15.143s requests=46542135 failures=0 heapBytes=60185208 rssBytes=103088128 goroutines=303 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:7}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=46443672 slowUploads=93583 slowDownloads=4880 uploadedBytes=24532221952 downloadedBytes=40936407040 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=26m30.143s requests=46985865 failures=0 heapBytes=82859896 rssBytes=121839616 goroutines=364 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=46886464 slowUploads=94473 slowDownloads=4928 uploadedBytes=24765530112 downloadedBytes=41339060224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=26m45.143s requests=47426140 failures=0 heapBytes=76512768 rssBytes=121827328 goroutines=319 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=47325797 slowUploads=95367 slowDownloads=4976 uploadedBytes=24999886848 downloadedBytes=41741713408 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=27m0.143s requests=47864835 failures=0 heapBytes=96303144 rssBytes=115740672 goroutines=380 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=47763553 slowUploads=96258 slowDownloads=5024 uploadedBytes=25233457152 downloadedBytes=42144366592 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=27m15.143s requests=48307600 failures=0 heapBytes=74108016 rssBytes=93282304 goroutines=358 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=48205383 slowUploads=97149 slowDownloads=5068 uploadedBytes=25467027456 downloadedBytes=42513465344 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=27m30.143s requests=48750920 failures=0 heapBytes=48575472 rssBytes=90746880 goroutines=376 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=48647765 slowUploads=98043 slowDownloads=5112 uploadedBytes=25701384192 downloadedBytes=42882564096 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=27m45.143s requests=49191794 failures=0 heapBytes=89907488 rssBytes=120475648 goroutines=357 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=49087700 slowUploads=98934 slowDownloads=5160 uploadedBytes=25934954496 downloadedBytes=43285217280 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=28m0.143s requests=49637025 failures=0 heapBytes=71241912 rssBytes=120827904 goroutines=338 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=49531996 slowUploads=99826 slowDownloads=5208 uploadedBytes=26168786944 downloadedBytes=43687870464 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=28m15.143s requests=50077201 failures=0 heapBytes=71823760 rssBytes=121327616 goroutines=342 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=49971226 slowUploads=100719 slowDownloads=5256 uploadedBytes=26402881536 downloadedBytes=44090523648 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=28m30.143s requests=50524644 failures=0 heapBytes=88321680 rssBytes=107315200 goroutines=315 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=50417733 slowUploads=101610 slowDownloads=5303 uploadedBytes=26636451840 downloadedBytes=44484788224 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=28m45.143s requests=50971603 failures=0 heapBytes=49721352 rssBytes=87646208 goroutines=335 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:9}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=50863761 slowUploads=102501 slowDownloads=5346 uploadedBytes=26870022144 downloadedBytes=44845498368 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=29m0.143s requests=51417802 failures=0 heapBytes=55859872 rssBytes=104423424 goroutines=356 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:8}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=51309017 slowUploads=103394 slowDownloads=5392 uploadedBytes=27104116736 downloadedBytes=45231374336 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=29m15.143s requests=51862535 failures=0 heapBytes=63482984 rssBytes=121856000 goroutines=349 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:10}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=51752810 slowUploads=104285 slowDownloads=5440 uploadedBytes=27337687040 downloadedBytes=45634027520 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=29m30.143s requests=52305456 failures=0 heapBytes=85512792 rssBytes=118308864 goroutines=332 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:15}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=52194795 slowUploads=105177 slowDownloads=5488 uploadedBytes=27571519488 downloadedBytes=46036680704 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=29m45.143s requests=52747376 failures=0 heapBytes=74143584 rssBytes=114286592 goroutines=333 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:16}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=52635773 slowUploads=106069 slowDownloads=5536 uploadedBytes=27805351936 downloadedBytes=46439333888 serverFailures=0
|
||||
mixedSoak_test.go:135: phase=running elapsed=30m0.143s requests=53191576 failures=0 heapBytes=43650296 rssBytes=97910784 goroutines=272 fileDescriptors=23 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:8 ActiveRequests:12}
|
||||
mixedSoak_test.go:124: mixedTotals phase=running health=53079033 slowUploads=106963 slowDownloads=5580 uploadedBytes=28039708672 downloadedBytes=46808432640 serverFailures=0
|
||||
mixedSoak_test.go:190: phase=after elapsed=30m2.635s requests=53191687 failures=0 heapBytes=44586352 rssBytes=97927168 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:5 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
mixedSoak_test.go:124: mixedTotals phase=after health=53079128 slowUploads=106971 slowDownloads=5588 uploadedBytes=28041805824 downloadedBytes=46875541504 serverFailures=0
|
||||
mixedSoak_test.go:196: connection=0 health=6632549 slowUploads=13372 slowDownloads=698
|
||||
mixedSoak_test.go:196: connection=1 health=6629530 slowUploads=13371 slowDownloads=699
|
||||
mixedSoak_test.go:196: connection=2 health=6636544 slowUploads=13376 slowDownloads=699
|
||||
mixedSoak_test.go:196: connection=3 health=6634170 slowUploads=13369 slowDownloads=698
|
||||
mixedSoak_test.go:196: connection=4 health=6636236 slowUploads=13368 slowDownloads=698
|
||||
mixedSoak_test.go:196: connection=5 health=6640096 slowUploads=13371 slowDownloads=698
|
||||
mixedSoak_test.go:196: connection=6 health=6634043 slowUploads=13372 slowDownloads=699
|
||||
mixedSoak_test.go:196: connection=7 health=6635960 slowUploads=13372 slowDownloads=699
|
||||
mixedSoak_test.go:209: phase=afterReclaim elapsed=30m2.639s requests=53191687 failures=0 heapBytes=461176 rssBytes=95506432 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:8 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:5 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestTLSMixedSoak (1802.64s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 1802.651s
|
||||
PASS HTTP/2 TLS soak
|
||||
42
performance/http2ProxyCleanupLinux.json
Normal file
42
performance/http2ProxyCleanupLinux.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"testedCommit": "b54a303ccdc034cd3062e846c6c6b2c24e55ffaa",
|
||||
"runnerSha256": "9ef2774b1d19c05f1ae8d9527665dba541561a7c677f9984a24e72d5bddb6ad5",
|
||||
"startedAt": "2026-09-23T03:13:01.977797+00:00",
|
||||
"cases": [
|
||||
{
|
||||
"case": "wallTimeout",
|
||||
"status": "passed",
|
||||
"expectedFailureExitCode": 1,
|
||||
"runnerError": "Command '['/usr/bin/docker', 'run', '--rm', '--name', 'go-omni-http2-proxy-ba2aaeecdd00-build', '--cpus', '2', '--memory', '2048m', '--memory-swap', '2048m', '--pids-limit', '256', '--log-opt', 'max-size=10m', '--log-opt', 'max-file=1', '-v', '/root/go-omni-validation:/workspace:ro', '-v', '/tmp/goOmniHTTP2Proxyi0b0o9y6:/output', '-w', '/workspace', '-e', 'GOWORK=off', '-e', 'GOMAXPROCS=2', '-e', 'CGO_ENABLED=0', '-e', 'GOPROXY=https://goproxy.cn,direct', 'golang:1.25', 'sh', '-c', 'go version && go vet ./transport/http2 && go test -c -o /output/http2.test ./transport/http2']' timed out after 1.8251966070383787 seconds",
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-ba2aaeecdd00-build"
|
||||
],
|
||||
"preExistingContainersStillRunning": 7,
|
||||
"remainingValidationContainers": []
|
||||
},
|
||||
{
|
||||
"case": "sigterm",
|
||||
"status": "passed",
|
||||
"expectedFailureExitCode": 1,
|
||||
"runnerError": "KeyboardInterrupt",
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-6307142e05ee-build"
|
||||
],
|
||||
"preExistingContainersStillRunning": 7,
|
||||
"remainingValidationContainers": []
|
||||
},
|
||||
{
|
||||
"case": "sighup",
|
||||
"status": "passed",
|
||||
"expectedFailureExitCode": 1,
|
||||
"runnerError": "KeyboardInterrupt",
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-dcf3bd54fbe4-build"
|
||||
],
|
||||
"preExistingContainersStillRunning": 7,
|
||||
"remainingValidationContainers": []
|
||||
}
|
||||
],
|
||||
"status": "passed",
|
||||
"finishedAt": "2026-09-23T03:13:06.934079+00:00"
|
||||
}
|
||||
142
performance/http2ProxyLinux.json
Normal file
142
performance/http2ProxyLinux.json
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"startedAt": "2026-09-23T03:10:31.408631+00:00",
|
||||
"testedCommit": "b54a303ccdc034cd3062e846c6c6b2c24e55ffaa",
|
||||
"workingTreeStatus": "",
|
||||
"sourceSha256": {
|
||||
"go.mod": "28d68eb2c0a019cceea884e88ab2335f44ad930e1890e28871638f4b073b4f86",
|
||||
"go.sum": "d9c2a5d0de94f08bd18a14a9626f9dda559c245d89bf8a689e32acbd2136fb05",
|
||||
"scripts/verifyHTTP2Proxy.py": "9ef2774b1d19c05f1ae8d9527665dba541561a7c677f9984a24e72d5bddb6ad5",
|
||||
"transport/http2/admission_test.go": "342a7d02bba508100a207b77246ca92e0fa04dd5f8e4a27091a01866b6750d39",
|
||||
"transport/http2/flowControl_test.go": "595b2e937cef2142ff6b6386116e338d874d0fe49e63d80f6de2e1268378dba2",
|
||||
"transport/http2/frameObserver.go": "fcdf431f8269ad9cfd65dd14102ce5c42ac8c4b8b833ed42c4ae15f2a7c7f14c",
|
||||
"transport/http2/lifecycle_test.go": "370a16de8b20248bb78021ab338b9f62ec51f733f270c0fa92f48b1a3ccb9eae",
|
||||
"transport/http2/mixedSoak_test.go": "2111737fe0405bc809bf06325573f7356160ae7fcaebcce06dcd94b2d9e7dccd",
|
||||
"transport/http2/proxy_test.go": "808d0fa7e6c3b0237b1adf4a71a4f30ae2dbc7d1a9d53fab164cda33b15c9d69",
|
||||
"transport/http2/server.go": "aeecdc3aec42219862d70ec788bef5df332189895555bc5d390849a51bd0fbfb",
|
||||
"transport/http2/server_test.go": "c4613e77fa885b6362c06c393937c7dcf9a7ffb4a9a32f85e21fc2f741de81a6",
|
||||
"transport/http2/soak_test.go": "52baa37e37dba497c579b6b55d723bda866ac57676254dc878c511345acc5af2"
|
||||
},
|
||||
"repetitions": 3,
|
||||
"race": false,
|
||||
"containerLimits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memoryAndSwapBytes": 2147483648,
|
||||
"pidsLimit": 256
|
||||
},
|
||||
"wallTimeoutSeconds": 600,
|
||||
"imageIds": {
|
||||
"golang:1.25": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"nginx:1.30.5-alpine": "sha256:bf3201ab56f23e5954646379c775d511fc466e9f11376d9725361064ad07ed35"
|
||||
},
|
||||
"notes": [
|
||||
"Compiler and integration stages run sequentially; at most one 2-CPU/2-GiB validation container is active.",
|
||||
"The integration container has network=none, no published ports and a read-only root filesystem. Client, Nginx and origins communicate over loopback inside that container.",
|
||||
"Both TLS legs validate certificates; invalid upstream CA and hostname must fail before reaching the handler.",
|
||||
"HTTP/2 is verified independently on both legs. This is a functional integration test, not a throughput benchmark or a live Cloudflare deployment.",
|
||||
"Single-process Nginx fixture with generated test certificates. Rolling drain switches between old and backup origins; this does not test Nginx master/worker configuration reloads or live Cloudflare routing."
|
||||
],
|
||||
"proxyVersions": [
|
||||
"nginx version: nginx/1.30.5"
|
||||
],
|
||||
"results": [
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}",
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}",
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}"
|
||||
],
|
||||
"goVersion": "go1.25.14",
|
||||
"platform": "linux/amd64",
|
||||
"commands": [
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-proxy-52da1679e6df-build",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"-v",
|
||||
"/root/go-omni-validation:/workspace:ro",
|
||||
"-v",
|
||||
"/tmp/goOmniHTTP2Proxyk3cc3h1_:/output",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"CGO_ENABLED=0",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go vet ./transport/http2 && go test -c -o /output/http2.test ./transport/http2"
|
||||
],
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-proxy-52da1679e6df-check",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,noexec,size=128m",
|
||||
"-v",
|
||||
"/tmp/goOmniHTTP2Proxyk3cc3h1_:/test:ro",
|
||||
"--entrypoint",
|
||||
"/test/http2.test",
|
||||
"-e",
|
||||
"GO_OMNI_HTTP2_PROXY_TEST=1",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"nginx:1.30.5-alpine",
|
||||
"-test.v",
|
||||
"-test.run",
|
||||
"^TestNginxHTTP2Proxy$",
|
||||
"-test.timeout",
|
||||
"120s",
|
||||
"-test.count",
|
||||
"3"
|
||||
]
|
||||
],
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-52da1679e6df-build",
|
||||
"go-omni-http2-proxy-52da1679e6df-check"
|
||||
],
|
||||
"finishedAt": "2026-09-23T03:11:00.363119+00:00",
|
||||
"logSha256": "1263ceb8ce7b65f542898e17031ba56127672d363defd0ee6520199fa1927472",
|
||||
"preExistingContainersStillRunning": 7
|
||||
}
|
||||
40
performance/http2ProxyLinux.txt
Normal file
40
performance/http2ProxyLinux.txt
Normal file
@@ -0,0 +1,40 @@
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (1.03s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.39s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.42s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.22s)
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (0.81s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.17s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.45s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.20s)
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (0.89s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.21s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.44s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.23s)
|
||||
PASS
|
||||
147
performance/http2ProxyRaceLinux.json
Normal file
147
performance/http2ProxyRaceLinux.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"startedAt": "2026-09-23T03:11:00.470197+00:00",
|
||||
"testedCommit": "b54a303ccdc034cd3062e846c6c6b2c24e55ffaa",
|
||||
"workingTreeStatus": "",
|
||||
"sourceSha256": {
|
||||
"go.mod": "28d68eb2c0a019cceea884e88ab2335f44ad930e1890e28871638f4b073b4f86",
|
||||
"go.sum": "d9c2a5d0de94f08bd18a14a9626f9dda559c245d89bf8a689e32acbd2136fb05",
|
||||
"scripts/verifyHTTP2Proxy.py": "9ef2774b1d19c05f1ae8d9527665dba541561a7c677f9984a24e72d5bddb6ad5",
|
||||
"transport/http2/admission_test.go": "342a7d02bba508100a207b77246ca92e0fa04dd5f8e4a27091a01866b6750d39",
|
||||
"transport/http2/flowControl_test.go": "595b2e937cef2142ff6b6386116e338d874d0fe49e63d80f6de2e1268378dba2",
|
||||
"transport/http2/frameObserver.go": "fcdf431f8269ad9cfd65dd14102ce5c42ac8c4b8b833ed42c4ae15f2a7c7f14c",
|
||||
"transport/http2/lifecycle_test.go": "370a16de8b20248bb78021ab338b9f62ec51f733f270c0fa92f48b1a3ccb9eae",
|
||||
"transport/http2/mixedSoak_test.go": "2111737fe0405bc809bf06325573f7356160ae7fcaebcce06dcd94b2d9e7dccd",
|
||||
"transport/http2/proxy_test.go": "808d0fa7e6c3b0237b1adf4a71a4f30ae2dbc7d1a9d53fab164cda33b15c9d69",
|
||||
"transport/http2/server.go": "aeecdc3aec42219862d70ec788bef5df332189895555bc5d390849a51bd0fbfb",
|
||||
"transport/http2/server_test.go": "c4613e77fa885b6362c06c393937c7dcf9a7ffb4a9a32f85e21fc2f741de81a6",
|
||||
"transport/http2/soak_test.go": "52baa37e37dba497c579b6b55d723bda866ac57676254dc878c511345acc5af2"
|
||||
},
|
||||
"repetitions": 3,
|
||||
"race": true,
|
||||
"containerLimits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memoryAndSwapBytes": 2147483648,
|
||||
"pidsLimit": 256
|
||||
},
|
||||
"wallTimeoutSeconds": 600,
|
||||
"imageIds": {
|
||||
"golang:1.25": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"nginx:1.30.5": "sha256:0aa2d81d65bc0cac0407e738b8f07d312c8685a84225fcb4db7bcbdd8c9bdf11"
|
||||
},
|
||||
"notes": [
|
||||
"Compiler and integration stages run sequentially; at most one 2-CPU/2-GiB validation container is active.",
|
||||
"The integration container has network=none, no published ports and a read-only root filesystem. Client, Nginx and origins communicate over loopback inside that container.",
|
||||
"Both TLS legs validate certificates; invalid upstream CA and hostname must fail before reaching the handler.",
|
||||
"HTTP/2 is verified independently on both legs. This is a functional integration test, not a throughput benchmark or a live Cloudflare deployment.",
|
||||
"Single-process Nginx fixture with generated test certificates. Rolling drain switches between old and backup origins; this does not test Nginx master/worker configuration reloads or live Cloudflare routing."
|
||||
],
|
||||
"proxyVersions": [
|
||||
"nginx version: nginx/1.30.5"
|
||||
],
|
||||
"results": [
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}",
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}",
|
||||
"tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true",
|
||||
"timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}",
|
||||
"goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}"
|
||||
],
|
||||
"goVersion": "go1.25.14",
|
||||
"platform": "linux/amd64",
|
||||
"commands": [
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"pull",
|
||||
"nginx:1.30.5"
|
||||
],
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-proxy-ca37da979ea6-build",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"-v",
|
||||
"/root/go-omni-validation:/workspace:ro",
|
||||
"-v",
|
||||
"/tmp/goOmniHTTP2Proxy8ll9pvvs:/output",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"CGO_ENABLED=1",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go vet ./transport/http2 && go test -race -c -o /output/http2.test ./transport/http2"
|
||||
],
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-http2-proxy-ca37da979ea6-check",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,noexec,size=128m",
|
||||
"-v",
|
||||
"/tmp/goOmniHTTP2Proxy8ll9pvvs:/test:ro",
|
||||
"--entrypoint",
|
||||
"/test/http2.test",
|
||||
"-e",
|
||||
"GO_OMNI_HTTP2_PROXY_TEST=1",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"nginx:1.30.5",
|
||||
"-test.v",
|
||||
"-test.run",
|
||||
"^TestNginxHTTP2Proxy$",
|
||||
"-test.timeout",
|
||||
"120s",
|
||||
"-test.count",
|
||||
"3"
|
||||
]
|
||||
],
|
||||
"cleanedContainers": [
|
||||
"go-omni-http2-proxy-ca37da979ea6-build",
|
||||
"go-omni-http2-proxy-ca37da979ea6-check"
|
||||
],
|
||||
"finishedAt": "2026-09-23T03:12:51.684028+00:00",
|
||||
"logSha256": "aaae7678dd4f882c51c008eaeb78afd897ccf2f7ba31c926a40839de755bb595",
|
||||
"preExistingContainersStillRunning": 7
|
||||
}
|
||||
67
performance/http2ProxyRaceLinux.txt
Normal file
67
performance/http2ProxyRaceLinux.txt
Normal file
@@ -0,0 +1,67 @@
|
||||
1.30.5: Pulling from library/nginx
|
||||
757049f14b94: Pulling fs layer
|
||||
af529266d394: Pulling fs layer
|
||||
3a55ec62cf16: Pulling fs layer
|
||||
2aa35770407f: Pulling fs layer
|
||||
f3cb4f951034: Pulling fs layer
|
||||
6b37362b3da7: Pulling fs layer
|
||||
b9614c6dbd86: Pulling fs layer
|
||||
2aa35770407f: Download complete
|
||||
d6363fd2ac35: Download complete
|
||||
3a55ec62cf16: Download complete
|
||||
02ac47b2a7a6: Download complete
|
||||
af529266d394: Download complete
|
||||
757049f14b94: Download complete
|
||||
f3cb4f951034: Download complete
|
||||
6b37362b3da7: Download complete
|
||||
6b37362b3da7: Pull complete
|
||||
b9614c6dbd86: Download complete
|
||||
3a55ec62cf16: Pull complete
|
||||
f3cb4f951034: Pull complete
|
||||
b9614c6dbd86: Pull complete
|
||||
2aa35770407f: Pull complete
|
||||
af529266d394: Pull complete
|
||||
757049f14b94: Pull complete
|
||||
Digest: sha256:0aa2d81d65bc0cac0407e738b8f07d312c8685a84225fcb4db7bcbdd8c9bdf11
|
||||
Status: Downloaded newer image for nginx:1.30.5
|
||||
docker.io/library/nginx:1.30.5
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (0.92s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.31s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.41s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.19s)
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (1.14s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.28s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.56s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.30s)
|
||||
=== RUN TestNginxHTTP2Proxy
|
||||
proxy_test.go:37: proxyVersion=nginx version: nginx/1.30.5
|
||||
=== RUN TestNginxHTTP2Proxy/TLSAndConnectionReuse
|
||||
proxy_test.go:163: proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true
|
||||
=== RUN TestNginxHTTP2Proxy/TimeoutAndCancellation
|
||||
proxy_test.go:206: proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats={AcceptedConnections:3 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
=== RUN TestNginxHTTP2Proxy/GoAwayAndRollingDrain
|
||||
proxy_test.go:281: proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:1 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestNginxHTTP2Proxy (0.92s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TLSAndConnectionReuse (0.31s)
|
||||
--- PASS: TestNginxHTTP2Proxy/TimeoutAndCancellation (0.44s)
|
||||
--- PASS: TestNginxHTTP2Proxy/GoAwayAndRollingDrain (0.16s)
|
||||
PASS
|
||||
84
performance/http2SmokeLinux.json
Normal file
84
performance/http2SmokeLinux.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"createdAt": "2026-09-22T17:22:44.674797+00:00",
|
||||
"platform": "linux/amd64",
|
||||
"image": "golang:1.25",
|
||||
"imageId": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"sourceSha256": {
|
||||
"scripts/verifyHTTP2.py": "2c9f4ca095e16e9b30b80b5d812b2644577222274e5a8041e0fb178e305197a4",
|
||||
"transport/http2/soak_test.go": "9964a1670c8ffe30b27e96375e0ae72d00996b59beede748756013e8fd52eacf"
|
||||
},
|
||||
"containerLimits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memoryAndSwapBytes": 2147483648,
|
||||
"pids": 256
|
||||
},
|
||||
"gomaxprocs": 2,
|
||||
"workers": 128,
|
||||
"notes": [
|
||||
"Bounded smoke validation, not completion of the 30-minute HTTP/2 acceptance gate.",
|
||||
"TLS HTTP/2 over container loopback, with the client and server in one process; memory is not server-only.",
|
||||
"One TCP connection multiplexes the 128 workers; each response checks HTTP/2, status, body and a unique request ID.",
|
||||
"MiB uses 1024*1024 bytes. RSS is /proc/self/statm; heap is runtime.ReadMemStats. Samples every 15 seconds.",
|
||||
"The after sample includes the one monitoring goroutine; it is stopped and joined before the test returns.",
|
||||
"All seven pre-existing containers remained running, and no soak containers remained after cleanup.",
|
||||
"The prior unbounded 30-minute race run was stopped after RSS growth; its result is not counted as a pass.",
|
||||
"The short A/B comparison does not establish the complete cause of the old server incident."
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"mode": "regular",
|
||||
"duration": "2m",
|
||||
"elapsedSeconds": 120.04,
|
||||
"requests": 3885273,
|
||||
"failures": 0,
|
||||
"protocolErrors": 0,
|
||||
"sampledPeakRssBytes": 22437888,
|
||||
"before": {
|
||||
"heapBytes": 168040,
|
||||
"rssBytes": 8491008,
|
||||
"goroutines": 2,
|
||||
"fileDescriptors": 6
|
||||
},
|
||||
"after": {
|
||||
"heapBytes": 3925848,
|
||||
"rssBytes": 21364736,
|
||||
"goroutines": 3,
|
||||
"fileDescriptors": 6,
|
||||
"activeConnections": 0,
|
||||
"activeRequests": 0
|
||||
},
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"mode": "race",
|
||||
"duration": "60s",
|
||||
"elapsedSeconds": 60.127,
|
||||
"requests": 377788,
|
||||
"failures": 0,
|
||||
"protocolErrors": 0,
|
||||
"sampledPeakRssBytes": 492707840,
|
||||
"before": {
|
||||
"heapBytes": 169256,
|
||||
"rssBytes": 23732224,
|
||||
"goroutines": 2,
|
||||
"fileDescriptors": 6
|
||||
},
|
||||
"after": {
|
||||
"heapBytes": 2722632,
|
||||
"rssBytes": 487239680,
|
||||
"goroutines": 3,
|
||||
"fileDescriptors": 6,
|
||||
"activeConnections": 0,
|
||||
"activeRequests": 0
|
||||
},
|
||||
"exitCode": 0
|
||||
}
|
||||
],
|
||||
"cleanupValidation": {
|
||||
"sigterm": "passed",
|
||||
"sighup": "passed",
|
||||
"wallTimeout": "passed"
|
||||
},
|
||||
"thirtyMinuteAcceptance": "pending"
|
||||
}
|
||||
2624
performance/http2SoakLinux.json
Normal file
2624
performance/http2SoakLinux.json
Normal file
File diff suppressed because it is too large
Load Diff
133
performance/http2SoakLinux.txt
Normal file
133
performance/http2SoakLinux.txt
Normal file
@@ -0,0 +1,133 @@
|
||||
HTTP/2 Linux soak image: golang:1.25
|
||||
HTTP/2 TLS soak duration: 30m
|
||||
HTTP/2 TLS soak mode: regular
|
||||
HTTP/2 soak container: go-omni-http2-soak-b8d45477120a; limits: 2 CPUs, 2048 MiB, 256 tasks
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
=== RUN TestTLSSoak
|
||||
soak_test.go:33: phase=before elapsed=0s requests=0 failures=0 heapBytes=168680 rssBytes=8441856 goroutines=2 fileDescriptors=6 stats={AcceptedConnections:0 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=15.06s requests=483494 failures=0 heapBytes=6913536 rssBytes=22216704 goroutines=240 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=30.06s requests=961350 failures=0 heapBytes=6818840 rssBytes=22302720 goroutines=327 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=45.06s requests=1434755 failures=0 heapBytes=5234048 rssBytes=21180416 goroutines=267 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=1m0.06s requests=1917020 failures=0 heapBytes=3959208 rssBytes=21983232 goroutines=256 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=1m15.06s requests=2405587 failures=0 heapBytes=6696432 rssBytes=22294528 goroutines=355 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=1m30.06s requests=2893800 failures=0 heapBytes=5880824 rssBytes=21860352 goroutines=335 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=1m45.06s requests=3381213 failures=0 heapBytes=4176008 rssBytes=22257664 goroutines=331 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=2m0.06s requests=3867826 failures=0 heapBytes=6923296 rssBytes=22417408 goroutines=299 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=2m15.06s requests=4355136 failures=0 heapBytes=4863216 rssBytes=22257664 goroutines=331 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=2m30.06s requests=4844005 failures=0 heapBytes=4875168 rssBytes=22208512 goroutines=352 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=2m45.06s requests=5331034 failures=0 heapBytes=4053832 rssBytes=22085632 goroutines=354 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=3m0.061s requests=5824954 failures=0 heapBytes=7276088 rssBytes=22253568 goroutines=276 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=3m15.06s requests=6316211 failures=0 heapBytes=6082672 rssBytes=22413312 goroutines=340 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=3m30.06s requests=6795314 failures=0 heapBytes=6157488 rssBytes=22106112 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=3m45.06s requests=7279828 failures=0 heapBytes=6538536 rssBytes=22454272 goroutines=296 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=4m0.06s requests=7763620 failures=0 heapBytes=5209696 rssBytes=22003712 goroutines=371 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=4m15.06s requests=8245520 failures=0 heapBytes=4119296 rssBytes=22532096 goroutines=325 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=4m30.06s requests=8728841 failures=0 heapBytes=6352704 rssBytes=22863872 goroutines=336 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=4m45.06s requests=9206490 failures=0 heapBytes=5696104 rssBytes=21614592 goroutines=227 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=5m0.06s requests=9692044 failures=0 heapBytes=6982152 rssBytes=22798336 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=5m15.06s requests=10181610 failures=0 heapBytes=4842816 rssBytes=23273472 goroutines=233 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=5m30.06s requests=10674122 failures=0 heapBytes=4987832 rssBytes=21663744 goroutines=390 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=5m45.06s requests=11158495 failures=0 heapBytes=4861520 rssBytes=21594112 goroutines=255 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=6m0.06s requests=11643125 failures=0 heapBytes=6995288 rssBytes=22351872 goroutines=370 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=6m15.06s requests=12134189 failures=0 heapBytes=5791656 rssBytes=22269952 goroutines=322 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=6m30.06s requests=12617974 failures=0 heapBytes=4615832 rssBytes=22204416 goroutines=293 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=6m45.06s requests=13109107 failures=0 heapBytes=6241656 rssBytes=22806528 goroutines=331 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=7m0.06s requests=13597755 failures=0 heapBytes=4717608 rssBytes=22777856 goroutines=355 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=7m15.06s requests=14082999 failures=0 heapBytes=7078376 rssBytes=23724032 goroutines=352 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=7m30.06s requests=14573956 failures=0 heapBytes=4402216 rssBytes=21753856 goroutines=364 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=7m45.06s requests=15062222 failures=0 heapBytes=5805904 rssBytes=22765568 goroutines=366 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=8m0.06s requests=15550159 failures=0 heapBytes=6706008 rssBytes=22376448 goroutines=332 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=8m15.06s requests=16032745 failures=0 heapBytes=5471120 rssBytes=22126592 goroutines=318 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=8m30.061s requests=16516193 failures=0 heapBytes=7097144 rssBytes=22880256 goroutines=365 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=8m45.061s requests=16995862 failures=0 heapBytes=5984144 rssBytes=22609920 goroutines=288 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=9m0.06s requests=17480917 failures=0 heapBytes=5254728 rssBytes=22745088 goroutines=284 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=9m15.06s requests=17969665 failures=0 heapBytes=5609544 rssBytes=22839296 goroutines=264 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=9m30.06s requests=18456021 failures=0 heapBytes=4073608 rssBytes=22032384 goroutines=291 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=9m45.06s requests=18943865 failures=0 heapBytes=5703584 rssBytes=22515712 goroutines=250 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=10m0.06s requests=19427994 failures=0 heapBytes=4484904 rssBytes=23121920 goroutines=286 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=10m15.06s requests=19911036 failures=0 heapBytes=5654336 rssBytes=22589440 goroutines=330 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=10m30.06s requests=20398330 failures=0 heapBytes=7287736 rssBytes=22564864 goroutines=262 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=10m45.06s requests=20885304 failures=0 heapBytes=4866728 rssBytes=22773760 goroutines=340 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=11m0.06s requests=21377882 failures=0 heapBytes=4500144 rssBytes=22474752 goroutines=197 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=11m15.06s requests=21859381 failures=0 heapBytes=4837744 rssBytes=22278144 goroutines=346 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=11m30.06s requests=22340499 failures=0 heapBytes=4255808 rssBytes=22675456 goroutines=265 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=11m45.06s requests=22831603 failures=0 heapBytes=7122880 rssBytes=22138880 goroutines=322 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=12m0.06s requests=23322670 failures=0 heapBytes=4139848 rssBytes=22515712 goroutines=325 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=12m15.06s requests=23810489 failures=0 heapBytes=6155784 rssBytes=22671360 goroutines=281 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=12m30.06s requests=24298600 failures=0 heapBytes=6864792 rssBytes=22568960 goroutines=196 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=12m45.06s requests=24784321 failures=0 heapBytes=3708712 rssBytes=22278144 goroutines=295 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=13m0.06s requests=25270364 failures=0 heapBytes=5901032 rssBytes=22634496 goroutines=337 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=13m15.06s requests=25748374 failures=0 heapBytes=5453384 rssBytes=22368256 goroutines=313 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=13m30.06s requests=26231067 failures=0 heapBytes=7024176 rssBytes=22790144 goroutines=259 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=13m45.06s requests=26712235 failures=0 heapBytes=6159920 rssBytes=23277568 goroutines=332 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=14m0.06s requests=27192429 failures=0 heapBytes=6571248 rssBytes=22429696 goroutines=281 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=14m15.06s requests=27674241 failures=0 heapBytes=5498824 rssBytes=22700032 goroutines=307 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=14m30.06s requests=28161405 failures=0 heapBytes=7281776 rssBytes=22188032 goroutines=365 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=14m45.06s requests=28643479 failures=0 heapBytes=5348280 rssBytes=22425600 goroutines=257 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=15m0.06s requests=29126808 failures=0 heapBytes=7028080 rssBytes=23273472 goroutines=264 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=15m15.06s requests=29606183 failures=0 heapBytes=5075368 rssBytes=21872640 goroutines=363 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=15m30.06s requests=30083605 failures=0 heapBytes=5377456 rssBytes=22351872 goroutines=343 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=15m45.06s requests=30567760 failures=0 heapBytes=4157600 rssBytes=22609920 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=16m0.06s requests=31051568 failures=0 heapBytes=4455056 rssBytes=22544384 goroutines=369 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=16m15.06s requests=31534567 failures=0 heapBytes=6660704 rssBytes=22876160 goroutines=271 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=16m30.06s requests=32010137 failures=0 heapBytes=6978528 rssBytes=22081536 goroutines=359 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=16m45.06s requests=32497005 failures=0 heapBytes=6214880 rssBytes=21352448 goroutines=252 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=17m0.06s requests=32977986 failures=0 heapBytes=4009360 rssBytes=21745664 goroutines=265 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=17m15.06s requests=33456000 failures=0 heapBytes=6263808 rssBytes=23236608 goroutines=344 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=17m30.06s requests=33943796 failures=0 heapBytes=4312032 rssBytes=22126592 goroutines=316 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=17m45.06s requests=34422999 failures=0 heapBytes=4311080 rssBytes=21950464 goroutines=237 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=18m0.06s requests=34905892 failures=0 heapBytes=4459192 rssBytes=22847488 goroutines=378 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=18m15.06s requests=35387063 failures=0 heapBytes=4965536 rssBytes=22679552 goroutines=272 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=18m30.06s requests=35872523 failures=0 heapBytes=7317240 rssBytes=22810624 goroutines=356 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=18m45.06s requests=36357886 failures=0 heapBytes=7180144 rssBytes=23207936 goroutines=379 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=19m0.06s requests=36847661 failures=0 heapBytes=6535800 rssBytes=22257664 goroutines=260 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=19m15.06s requests=37333781 failures=0 heapBytes=4758968 rssBytes=21053440 goroutines=261 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=19m30.06s requests=37819005 failures=0 heapBytes=5854656 rssBytes=22798336 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=19m45.06s requests=38302079 failures=0 heapBytes=6477368 rssBytes=23130112 goroutines=231 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=20m0.06s requests=38781438 failures=0 heapBytes=4912296 rssBytes=22179840 goroutines=291 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=20m15.06s requests=39266527 failures=0 heapBytes=6723304 rssBytes=22417408 goroutines=267 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=20m30.06s requests=39747087 failures=0 heapBytes=4455048 rssBytes=22949888 goroutines=282 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=20m45.06s requests=40229194 failures=0 heapBytes=7650552 rssBytes=22667264 goroutines=314 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=21m0.06s requests=40719856 failures=0 heapBytes=5024096 rssBytes=21934080 goroutines=327 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=21m15.06s requests=41201734 failures=0 heapBytes=4340216 rssBytes=22867968 goroutines=372 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=21m30.06s requests=41687427 failures=0 heapBytes=6020200 rssBytes=22179840 goroutines=356 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=21m45.06s requests=42169416 failures=0 heapBytes=5102400 rssBytes=21901312 goroutines=284 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=22m0.06s requests=42649854 failures=0 heapBytes=5587144 rssBytes=23244800 goroutines=257 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=22m15.061s requests=43134689 failures=0 heapBytes=7256936 rssBytes=22966272 goroutines=247 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=22m30.06s requests=43615393 failures=0 heapBytes=7194832 rssBytes=23044096 goroutines=370 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=22m45.06s requests=44094551 failures=0 heapBytes=4162944 rssBytes=22044672 goroutines=298 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=23m0.06s requests=44581507 failures=0 heapBytes=5801200 rssBytes=21823488 goroutines=266 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=23m15.06s requests=45066149 failures=0 heapBytes=4212824 rssBytes=22265856 goroutines=359 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=23m30.06s requests=45549300 failures=0 heapBytes=4478232 rssBytes=21921792 goroutines=340 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=23m45.06s requests=46035904 failures=0 heapBytes=6875824 rssBytes=22740992 goroutines=249 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=24m0.06s requests=46519641 failures=0 heapBytes=6247888 rssBytes=21938176 goroutines=291 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=24m15.06s requests=46997264 failures=0 heapBytes=7072560 rssBytes=23109632 goroutines=377 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=24m30.06s requests=47482230 failures=0 heapBytes=5093112 rssBytes=23351296 goroutines=392 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=24m45.06s requests=47955814 failures=0 heapBytes=5715440 rssBytes=22499328 goroutines=350 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=25m0.06s requests=48434219 failures=0 heapBytes=5736984 rssBytes=22720512 goroutines=336 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=25m15.06s requests=48911328 failures=0 heapBytes=7415632 rssBytes=22880256 goroutines=256 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=25m30.06s requests=49400802 failures=0 heapBytes=3811240 rssBytes=21762048 goroutines=347 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=25m45.06s requests=49885035 failures=0 heapBytes=4371400 rssBytes=22343680 goroutines=374 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:3}
|
||||
soak_test.go:96: phase=running elapsed=26m0.06s requests=50365248 failures=0 heapBytes=4201696 rssBytes=23511040 goroutines=324 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=26m15.06s requests=50845851 failures=0 heapBytes=4646712 rssBytes=22171648 goroutines=274 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=26m30.06s requests=51332514 failures=0 heapBytes=4957128 rssBytes=22810624 goroutines=264 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:0}
|
||||
soak_test.go:96: phase=running elapsed=26m45.06s requests=51818521 failures=0 heapBytes=5534424 rssBytes=22429696 goroutines=361 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=27m0.06s requests=52303713 failures=0 heapBytes=5460112 rssBytes=22978560 goroutines=264 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=27m15.06s requests=52785633 failures=0 heapBytes=6919824 rssBytes=22196224 goroutines=293 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=27m30.06s requests=53270255 failures=0 heapBytes=6976928 rssBytes=22519808 goroutines=273 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=27m45.06s requests=53754584 failures=0 heapBytes=6265560 rssBytes=23269376 goroutines=300 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=28m0.06s requests=54243260 failures=0 heapBytes=6019184 rssBytes=22319104 goroutines=304 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=28m15.06s requests=54727233 failures=0 heapBytes=6646888 rssBytes=22794240 goroutines=289 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=28m30.06s requests=55211460 failures=0 heapBytes=7514304 rssBytes=22654976 goroutines=345 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=28m45.06s requests=55691054 failures=0 heapBytes=4597656 rssBytes=21585920 goroutines=337 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:26}
|
||||
soak_test.go:96: phase=running elapsed=29m0.06s requests=56175794 failures=0 heapBytes=5366888 rssBytes=22618112 goroutines=240 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=29m15.06s requests=56657414 failures=0 heapBytes=5976448 rssBytes=22413312 goroutines=393 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=29m30.06s requests=57140848 failures=0 heapBytes=4945864 rssBytes=23572480 goroutines=276 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:2}
|
||||
soak_test.go:96: phase=running elapsed=29m45.06s requests=57624110 failures=0 heapBytes=4265224 rssBytes=22007808 goroutines=164 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:1}
|
||||
soak_test.go:96: phase=running elapsed=30m0.06s requests=58102348 failures=0 heapBytes=5912816 rssBytes=21807104 goroutines=333 fileDescriptors=9 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:0 RSTStreams:0 GoAwayFrames:0 ActiveConnections:1 ActiveRequests:13}
|
||||
soak_test.go:166: phase=after elapsed=30m0.067s requests=58102476 failures=0 heapBytes=4154504 rssBytes=21807104 goroutines=3 fileDescriptors=6 stats={AcceptedConnections:1 RejectedConnections:0 RejectedRequests:0 ProtocolErrors:0 GoAwaySent:1 RSTStreams:0 GoAwayFrames:0 ActiveConnections:0 ActiveRequests:0}
|
||||
--- PASS: TestTLSSoak (1800.07s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 1800.073s
|
||||
PASS HTTP/2 TLS soak
|
||||
50
performance/modulesAcceptance.md
Normal file
50
performance/modulesAcceptance.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 业务基础模块验收
|
||||
|
||||
2026-09-23,第一版功能验收通过。运行时代码提交:
|
||||
`b9259e628e84a4e1cc3a73801b04dd864c4f3a19`。
|
||||
使用方式与限制见 [MODULES.md](../MODULES.md)。
|
||||
|
||||
| 能力 | 验收内容 |
|
||||
| --- | --- |
|
||||
| cache | 字节切片所有权、LRU/容量、TTL 边界、取消/关闭、并发合并加载、独立等待取消、加载容量/超时/panic、存储故障不触发回源 |
|
||||
| cache/redis | 真实 Redis 二进制值读写、TTL 到期、零 TTL 持久、删除、WRONGTYPE 错误保留、取消写入不落库、关闭客户端错误不被当成 miss |
|
||||
| httpc | TCP 连接复用、GET 503 重试、POST/不可重放 body 不做应用层重试、总超时覆盖退避与响应体、响应大小、禁止自动重定向、请求 ID、提前拒绝时关闭请求体 |
|
||||
| middleware | 并发限流额度、补充与过期、429/503、504 和已提交响应;身份隔离、精确响应重放、内容冲突、在途记录不随 TTL 过期、失败/panic/超限记录、容量拒绝与超时组合 |
|
||||
| task | worker/队列上限、panic 隔离、正常排空、超时取消、队列任务不在强制关闭后启动、间隔任务不重叠、跳过统计 |
|
||||
| go-omni add | 模块命名/路径校验、符号链接拒绝、已有文件保护、独立消费项目编译和完整 CRUD、认证/校验/取消 |
|
||||
| 组合运行 | 同一示例在 nethttp/native/实验 HTTP/2 的真实 TLS 下验证:合并缓存、上游请求 ID、幂等重放、认证不可绕过、任务只提交一次和退出 |
|
||||
|
||||
macOS arm64 / Go 1.27.1 完成主体提交的全仓库 race、vet、build 与独立生成项目测试;
|
||||
最后的 HTTP 客户端请求体关闭修补另行通过 httpc race/vet。
|
||||
|
||||
Linux amd64 / Go 1.25.14 对上述最终运行时代码提交重新执行并通过:
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 -timeout=240s ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
随后使用 Redis 7.4.11 执行真实集成测试。Redis 测试二进制静态编译、未启用 race,
|
||||
适配器单元测试包含在前一阶段的全量 race 检查中。生成项目使用本地 replace 验证当前源码,
|
||||
本轮不包含新版本标签发布或远程 CLI 安装验收。
|
||||
|
||||
可复现入口:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyModules.py --goproxy https://goproxy.cn,direct
|
||||
```
|
||||
|
||||
两阶段容器顺序运行,单容器限制 2 CPU / 2 GiB 内存及内存+swap / 256 pids,
|
||||
总时限 900 秒;本次约 155 秒完成。Redis 容器以非 root 身份运行,禁外网、根文件系统只读,
|
||||
只使用内部 loopback,未发布端口。结束确认两个测试容器清理完毕,原有 7 个业务容器均仍运行。
|
||||
|
||||
原始证据:[结构化报告](modulesLinux.json)、[完整日志](modulesLinux.txt)。
|
||||
报告记录提交、各源码 SHA-256、镜像 ID、命令、资源上限和日志摘要,已与本地文件逐项核对。
|
||||
报告中的工作区状态仅为上一轮生成的两个未跟踪验收文件,没有未提交的运行时代码。
|
||||
|
||||
测试期间修正了任务池取消回调尚未执行时任务结果误报成功的竞态,
|
||||
以及幂等缓冲链未提交响应时 deadline 应交给外层 Timeout 返回 504 的组合行为。
|
||||
|
||||
这是功能与并发正确性验收,不是生产吞吐或跨副本一致性验收。当前限流、幂等、调度与合并回源
|
||||
是单进程能力;请求/任务超时依赖业务配合取消。native 和 HTTP/2 的成熟度沿用各自原有定义。
|
||||
159
performance/modulesLinux.json
Normal file
159
performance/modulesLinux.json
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"startedAt": "2026-09-23T05:41:28.082372+00:00",
|
||||
"testedCommit": "b9259e628e84a4e1cc3a73801b04dd864c4f3a19",
|
||||
"workingTreeStatus": "?? performance/modulesLinux.json\n?? performance/modulesLinux.txt",
|
||||
"sourceSha256": {
|
||||
"cache/cache.go": "4aad0a644c0c9ce6353e61e70059715bea5402f0d7132b50003ee126c3836cad",
|
||||
"cache/cache_test.go": "c850cd8a90c1e6a8d701a415e283d4147fbd66d12d054002014cbf348863aea2",
|
||||
"cache/memory.go": "3ac2a710a0d4677ad58dd6817005033ef7cc939be7eb3e40476212ce7f18ad42",
|
||||
"cache/redis/redis.go": "3f522258da8a0fc6627645573221e9f5ccbdfea0fe8a1450985cfd084e2c18ce",
|
||||
"cache/redis/redis_test.go": "4948647b2d31803252af1ee4e4b96372b53f9fac46f03ab064b40abe2d5a0aa1",
|
||||
"cmd/go-omni/main.go": "b778ec1659ca91772a8cd57e04a1190c9bc4f40652e8ec15a1acda6c0c820a96",
|
||||
"cmd/go-omni/main_test.go": "312e0dea1d46cbbd436acdb4fcac0704d4781746d9e8433f1203f67ef9601065",
|
||||
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
|
||||
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
|
||||
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
|
||||
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
|
||||
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
|
||||
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
|
||||
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
|
||||
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
|
||||
"cmd/go-omni/scaffold/scaffold.go": "15baca1b5c4607c8f96fb6f6d4b96a661a86899012f93a6c380bc6d894b18803",
|
||||
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
|
||||
"cmd/go-omni/scaffold/templates/README.md.tmpl": "dc5ce58c49b4c0b50a87fdca794513cad38f428975bdfe772e89944e39614bd4",
|
||||
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
|
||||
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
|
||||
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
|
||||
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
|
||||
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
|
||||
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
|
||||
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
|
||||
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
|
||||
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
|
||||
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "3fde559ac634a6c85d322dbc3487691a192644268e85aa3911dd2561a4116196",
|
||||
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "2504d9750194a543121c9e93328f5f2a535f1db9f8dbd361edf3681cdfa21c39",
|
||||
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
|
||||
"cmd/go-omni/scaffold/version.go": "4d2d1b0d6d0ea716ff4cb7ff08cc25e6f91e06b1a4585176e3128a22e2f2f4ad",
|
||||
"examples/modules/main.go": "6b44b981c0f654e241664c94612cd95ba8a394155b589add0814d81397156a26",
|
||||
"examples/modules/main_test.go": "4ceb869a7e1ab3fb037af5411ec3dec47af66738adb66c4db01498d446aab7fb",
|
||||
"go.mod": "a839f9bd2501f28fb712969465fcba94c04ad0aa4f80d4a1f189070e4cb1b4b3",
|
||||
"go.sum": "d2bd32eaf65d25d833c6481fe5af2dadd00dcdc73e2df3fcac2d4dc418d143fe",
|
||||
"httpc/client.go": "bbec35b6234a554a4aefd0fef97ace3850288642faab2e382dcbe19a6cb95cd8",
|
||||
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
|
||||
"httpx/context.go": "104434499f074eedf2c54e4d0e90db587feb230ca8bb76803b87f2e42d9a8118",
|
||||
"middleware/idempotency.go": "9b150d610e188cbd7d6783fc843563996c86d328024fd237e6a245977d30e299",
|
||||
"middleware/middleware.go": "a1e6cfae40d3e1701238a680009d66121f1bbaed786e81b3f6133eb1198a1cf6",
|
||||
"middleware/middleware_test.go": "9249aac16f251858bfe7937f768dc628bf4cc392af75f90223ccacd0f13d2cb9",
|
||||
"middleware/protection_test.go": "4347bb6074078c4c7bd9f65f5fa13a0ac2bc9a8615b297588cc3358aede69a7e",
|
||||
"middleware/rateLimit.go": "f3448dfbb773e4b91864f99e4afa2feb699f15e6ce100eb82d6fe4a426323a46",
|
||||
"middleware/request_id.go": "d52bf17bf80245bafe878026b044857442f0af8df689bcaed4d967d006cbd947",
|
||||
"middleware/timeout.go": "e2f446d992c9f0c986deb75fa71297f19c78f8e43593f174cd19fe93e385e440",
|
||||
"scripts/verifyModules.py": "e0ff3fa6ac074ceb4fd7e4c2a407df90dd20261c5d93b913b0d4b03bfc121d1b",
|
||||
"task/task.go": "ef671a9b7d84e7f53cdb2dcb787881e47b00ef0afb29fb6e4698fa8765a7bfe7",
|
||||
"task/task_test.go": "6e33ecd81345ece6102f48e60050a0d990e1bf75c3840e262d5da2962c743681"
|
||||
},
|
||||
"containerLimits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memoryAndSwapBytes": 2147483648,
|
||||
"pidsLimit": 256
|
||||
},
|
||||
"wallTimeoutSeconds": 900,
|
||||
"imageIds": {
|
||||
"golang:1.25": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"redis:7.4-alpine": "sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499"
|
||||
},
|
||||
"commands": [
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-modules-795f2a339ebc-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/goOmniModulesjyctce2e:/output",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"GO_OMNI_SCAFFOLD_INTEGRATION=1",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go test -race -count=1 -timeout=240s ./... && go vet ./... && go build ./... && CGO_ENABLED=0 go test -c -o /output/redis.test ./cache/redis"
|
||||
],
|
||||
[
|
||||
"/usr/bin/docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-modules-795f2a339ebc-redis",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--user",
|
||||
"999:999",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,noexec,size=64m,mode=1777",
|
||||
"-v",
|
||||
"/tmp/goOmniModulesjyctce2e:/test:ro",
|
||||
"--entrypoint",
|
||||
"sh",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"GO_OMNI_REDIS_TEST_ADDR=127.0.0.1:6379",
|
||||
"redis:7.4-alpine",
|
||||
"-c",
|
||||
"set -eu\nredis-server --version\nredis-server --bind 127.0.0.1 --port 6379 --save '' --appendonly no --maxmemory 32mb --daemonize yes --pidfile /tmp/redis.pid --logfile /tmp/redis.log --dir /tmp\ntrap 'redis-cli -h 127.0.0.1 shutdown nosave >/dev/null 2>&1 || true' EXIT\nattempt=0\nuntil redis-cli -h 127.0.0.1 ping >/dev/null 2>&1; do\n attempt=$((attempt + 1))\n if [ \"$attempt\" -gt 50 ]; then cat /tmp/redis.log; exit 1; fi\n sleep 0.1\ndone\n/test/redis.test -test.v -test.run '^TestRedisIntegration$' -test.count=1 -test.timeout=30s\n"
|
||||
]
|
||||
],
|
||||
"cleanedContainers": [
|
||||
"go-omni-modules-795f2a339ebc-build",
|
||||
"go-omni-modules-795f2a339ebc-redis"
|
||||
],
|
||||
"notes": [
|
||||
"Full repository race/vet/build plus independent generated-module CRUD in the compiler container.",
|
||||
"Real Redis uses a statically linked non-race test binary, loopback only, network=none and no published ports.",
|
||||
"At most one validation container; no pre-existing service or database is accessed."
|
||||
],
|
||||
"logSha256": "f069a476609b98ea803d193dc6d8bd49a0a4ed2ccdda9a139bc4413f5565764a",
|
||||
"finishedAt": "2026-09-23T05:44:03.152464+00:00",
|
||||
"preExistingContainersStillRunning": 7
|
||||
}
|
||||
53
performance/modulesLinux.txt
Normal file
53
performance/modulesLinux.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading github.com/redis/go-redis/v9 v9.17.3
|
||||
go: downloading golang.org/x/mod v0.17.0
|
||||
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/cespare/xxhash/v2 v2.3.0
|
||||
go: downloading github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
|
||||
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.085s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/app 1.644s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/auth 1.036s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cache 1.034s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 1.014s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.186s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 3.732s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/config 1.036s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/database 1.014s
|
||||
? 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/modules 1.070s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.171s
|
||||
? 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/httpc 1.104s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.034s
|
||||
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.035s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.060s
|
||||
? git.yuntuo.hk/k2safe/go-omni/response [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/task 1.118s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport 1.069s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 10.636s
|
||||
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.716s
|
||||
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]
|
||||
Redis server v=7.4.11 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64 build=40ff01a501d8e4b6
|
||||
=== RUN TestRedisIntegration
|
||||
--- PASS: TestRedisIntegration (0.08s)
|
||||
PASS
|
||||
55
performance/transportStartup.md
Normal file
55
performance/transportStartup.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 统一启动配置与业务接入验收
|
||||
|
||||
2026-09-23,代码提交 `be620d2243ff85288f0afc44689c2e46abd714ec`。
|
||||
|
||||
`omni.New(omni.WithConfig(settings))` 现在通过 `Run`、`RunContext` 或 `ServeContext`
|
||||
统一选择 nethttp、native 或实验 HTTP/2,加载 TLS 证书,并应用退出等待时间。
|
||||
默认仍是 nethttp;HTTP/2 必须显式选择并配置 TLS,native 仍只支持 HTTP/1.1。
|
||||
`RunWith` 继续支持自定义 Runner,由 Runner 管理自己的 TLS、超时及其他网络参数。
|
||||
|
||||
## 验证范围
|
||||
|
||||
| 场景 | 检查 |
|
||||
| --- | --- |
|
||||
| nethttp / native 明文;nethttp / native / HTTP2 TLS | 参考业务通过真实 TCP 执行 CRUD、分页、认证权限、参数错误、冲突和 404;校验响应及日志 requestId |
|
||||
| 三种 TLS 启动 | 使用临时受信任证书,验证证书链及 ALPN;nethttp、HTTP2 为 h2,native 为 http/1.1 |
|
||||
| 配置错误 | 未知适配器、HTTP2 缺 TLS、证书不配对、证书无法读取,在监听前失败且只清理一次 |
|
||||
| 已有 listener / 自定义 Runner | 配置失败或取消时关闭 listener;自定义 Runner 入口继续独立于内置适配器选择 |
|
||||
| 正常关闭 | 停止监听后,在途请求仍能成功完成,然后清理资源 |
|
||||
| 关闭超时 | 取消请求、关闭连接并返回超时;不响应取消的 Handler 不会无限阻塞框架返回 |
|
||||
| native 响应完成与关闭并发 | 在响应已经选择 Keep-Alive 后暂停写入,再触发关闭;响应写完后连接立即回收,退出无超时 |
|
||||
|
||||
框架无法强制终止不响应取消的业务 goroutine。业务仍需处理 context 取消;每个 native Server
|
||||
只用于一次服务生命周期,重启时创建新实例,以免超时后的旧 Handler 与新实例混用。
|
||||
|
||||
## Linux 结果
|
||||
|
||||
Go 1.25.14、Linux amd64、`golang:1.25` 镜像的实际 image ID、完整命令与代码提交
|
||||
记录于 [通过报告](transportStartupLinux.json),[原始输出](transportStartupLinux.txt) 的 SHA-256 已核对。
|
||||
|
||||
- 全仓库 `go test -race -p 1 -count=1 -timeout=3m ./...`、`go vet -p 1 ./...`、`go build -p 1 ./...` 通过。
|
||||
- 上表的配置、TLS 业务、退出和 native 并发回收测试重复 10 次,全部通过。
|
||||
- 单个验证容器限制为 2 核、2 GiB 内存、同额 memory-swap(无额外 swap)、256 个进程/线程,GOMAXPROCS=2。
|
||||
- Docker inspect 已确认限制生效,代码只读挂载,没有发布端口;测试流量仅访问容器内 loopback。
|
||||
- 总时限 480 秒;完成后验证容器已删除,之前运行的 7 个业务容器仍在运行。
|
||||
|
||||
首轮重复测试曾在 native 排空阶段失败,记录保留在
|
||||
[修复前报告](transportStartupBeforeFixLinux.json) 和 [修复前输出](transportStartupBeforeFixLinux.txt)。
|
||||
原因是响应写入和关闭并发时,连接转为空闲后可能再次等待 Keep-Alive 请求,直到关闭超时。
|
||||
新增暂停响应写入的确定性测试在修复前失败;修复将“转为空闲”和“判断是否关闭”置于同一临界区,
|
||||
最终报告包含修复后的重复回归。首轮失败不计作通过证据。
|
||||
|
||||
## 本地与脚手架
|
||||
|
||||
macOS arm64、Go 1.27.1 的全仓库 race、vet、build 通过;app/native 包额外重复 10 次 race 检查通过。
|
||||
实际生成的独立业务模块通过以下入口验证 native 启动、HTTP 请求、SIGINT / SIGTERM、退出码 0、
|
||||
端口关闭,以及 `omni.New().Run` 的资源清理回调:
|
||||
|
||||
```sh
|
||||
go build -o /tmp/go-omni-startup-cli ./cmd/go-omni
|
||||
python3 scripts/verifyScaffold.py --cli /tmp/go-omni-startup-cli --framework-dir . --transport native
|
||||
```
|
||||
|
||||
独立模块通过本地 replace 引用被测源码;这不表示旧的 `v0.6.0-preview` 发布标签已包含新增配置。
|
||||
本轮是功能、并发和生命周期验收,没有重新进行 30 分钟长测,也不代表 native 已合并 HTTP/2
|
||||
或已经在真实业务环境发布。下一步是业务试用及 native 多协议监听器的独立设计与验收。
|
||||
69
performance/transportStartupBeforeFixLinux.json
Normal file
69
performance/transportStartupBeforeFixLinux.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"startedAt": "2026-09-23T04:51:00.973573+00:00",
|
||||
"commit": "8e18528fc2355354e63e16fbb1742bd5fdce1789",
|
||||
"imageId": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"command": [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-startup-ea6d51f9c46e",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"-v",
|
||||
"/root/go-omni-validation:/workspace:ro",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go test -race -p 1 -count=1 -timeout=3m ./... && go vet -p 1 ./... && go build -p 1 ./... && go test -race -p 1 -count=3 -timeout=90s -v ./app ./examples/reference -run 'Test(Configured|RunContextConfigured|RunWithRemains|BusinessConfigured)'"
|
||||
],
|
||||
"limits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memorySwapBytes": 2147483648,
|
||||
"pids": 256
|
||||
},
|
||||
"wallTimeoutSeconds": 480,
|
||||
"businessContainersBefore": [
|
||||
"etax-mysql-test-mysql-1",
|
||||
"etax-api-test-api-1",
|
||||
"etax-event-worker-test-event-worker-1",
|
||||
"etax-event-scheduler-test-event-scheduler-1",
|
||||
"etax-id-test-id-service-a-1",
|
||||
"etax-id-test-id-service-b-1",
|
||||
"etax-clamav-test-clamav-1"
|
||||
],
|
||||
"publishedPorts": [],
|
||||
"businessContainersAfter": [
|
||||
"etax-mysql-test-mysql-1",
|
||||
"etax-api-test-api-1",
|
||||
"etax-event-worker-test-event-worker-1",
|
||||
"etax-event-scheduler-test-event-scheduler-1",
|
||||
"etax-id-test-id-service-a-1",
|
||||
"etax-id-test-id-service-b-1",
|
||||
"etax-clamav-test-clamav-1"
|
||||
],
|
||||
"businessContainersPreserved": true,
|
||||
"validationContainerRemoved": true,
|
||||
"finishedAt": "2026-09-23T04:53:23.164450+00:00",
|
||||
"exitCode": 1,
|
||||
"logSha256": "2ae3f967d3c5fed1c3534deccec988b9a7d99484cfa5da5851ebec9d6178437f"
|
||||
}
|
||||
196
performance/transportStartupBeforeFixLinux.txt
Normal file
196
performance/transportStartupBeforeFixLinux.txt
Normal file
@@ -0,0 +1,196 @@
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading github.com/go-sql-driver/mysql v1.9.3
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading github.com/go-playground/validator/v10 v10.20.0
|
||||
go: downloading filippo.io/edwards25519 v1.1.0
|
||||
go: downloading github.com/gabriel-vasile/mimetype v1.4.3
|
||||
go: downloading github.com/go-playground/universal-translator v0.18.1
|
||||
go: downloading github.com/leodido/go-urn v1.4.0
|
||||
go: downloading golang.org/x/crypto v0.23.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
go: downloading github.com/go-playground/locales v0.14.1
|
||||
ok git.yuntuo.hk/k2safe/go-omni 1.086s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/app 1.395s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/auth 1.037s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.127s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 1.133s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/config 1.035s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/database 1.013s
|
||||
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
|
||||
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.151s
|
||||
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.013s
|
||||
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/health 1.039s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.033s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.047s
|
||||
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/logx 1.013s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.036s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.036s
|
||||
? git.yuntuo.hk/k2safe/go-omni/response [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport 1.053s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.452s
|
||||
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.053s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.034s
|
||||
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
transport_test.go:98: context deadline exceeded
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- FAIL: TestConfiguredTransportLifecycle (3.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- FAIL: TestConfiguredTransportLifecycle/native/drain (3.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
FAIL
|
||||
FAIL git.yuntuo.hk/k2safe/go-omni/app 4.025s
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.297s
|
||||
FAIL
|
||||
81
performance/transportStartupLinux.json
Normal file
81
performance/transportStartupLinux.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"startedAt": "2026-09-23T04:57:45.377028+00:00",
|
||||
"commit": "be620d2243ff85288f0afc44689c2e46abd714ec",
|
||||
"imageId": "sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80",
|
||||
"command": [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"go-omni-startup-4da776773736",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--memory",
|
||||
"2048m",
|
||||
"--memory-swap",
|
||||
"2048m",
|
||||
"--pids-limit",
|
||||
"256",
|
||||
"--log-opt",
|
||||
"max-size=10m",
|
||||
"--log-opt",
|
||||
"max-file=1",
|
||||
"-v",
|
||||
"/root/go-omni-validation:/workspace:ro",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"-e",
|
||||
"GOWORK=off",
|
||||
"-e",
|
||||
"GOMAXPROCS=2",
|
||||
"-e",
|
||||
"GOPROXY=https://goproxy.cn,direct",
|
||||
"golang:1.25",
|
||||
"sh",
|
||||
"-c",
|
||||
"go version && go test -race -p 1 -count=1 -timeout=3m ./... && go vet -p 1 ./... && go build -p 1 ./... && go test -race -p 1 -count=10 -timeout=120s -v ./app ./examples/reference ./transport/native -run 'Test(Configured|RunContextConfigured|RunWithRemains|BusinessConfigured|ShutdownDuringResponse)'"
|
||||
],
|
||||
"limits": {
|
||||
"cpus": 2,
|
||||
"memoryBytes": 2147483648,
|
||||
"memorySwapBytes": 2147483648,
|
||||
"pids": 256
|
||||
},
|
||||
"wallTimeoutSeconds": 480,
|
||||
"businessContainersBefore": [
|
||||
"etax-mysql-test-mysql-1",
|
||||
"etax-api-test-api-1",
|
||||
"etax-event-worker-test-event-worker-1",
|
||||
"etax-event-scheduler-test-event-scheduler-1",
|
||||
"etax-id-test-id-service-a-1",
|
||||
"etax-id-test-id-service-b-1",
|
||||
"etax-clamav-test-clamav-1"
|
||||
],
|
||||
"publishedPorts": [],
|
||||
"businessContainersAfter": [
|
||||
"etax-mysql-test-mysql-1",
|
||||
"etax-api-test-api-1",
|
||||
"etax-event-worker-test-event-worker-1",
|
||||
"etax-event-scheduler-test-event-scheduler-1",
|
||||
"etax-id-test-id-service-a-1",
|
||||
"etax-id-test-id-service-b-1",
|
||||
"etax-clamav-test-clamav-1"
|
||||
],
|
||||
"businessContainersPreserved": true,
|
||||
"validationContainerRemoved": true,
|
||||
"finishedAt": "2026-09-23T05:00:13.275484+00:00",
|
||||
"exitCode": 0,
|
||||
"logSha256": "41ea889d2df841c5670e0526c1931a727c741fd42a2c51e47ac34302b1ca5434",
|
||||
"observedLimits": {
|
||||
"nanoCpus": 2000000000,
|
||||
"memoryBytes": 2147483648,
|
||||
"memorySwapBytes": 2147483648,
|
||||
"pids": 256,
|
||||
"portBindings": {},
|
||||
"binds": [
|
||||
"/root/go-omni-validation:/workspace:ro"
|
||||
]
|
||||
},
|
||||
"scope": "Full repository race tests, vet and build; ten repeats of configured startup, TLS business, graceful drain, forced shutdown and native idle-transition regression.",
|
||||
"previousFailure": "transportStartupBeforeFixLinux.json"
|
||||
}
|
||||
646
performance/transportStartupLinux.txt
Normal file
646
performance/transportStartupLinux.txt
Normal file
@@ -0,0 +1,646 @@
|
||||
go version go1.25.14 linux/amd64
|
||||
go: downloading github.com/go-sql-driver/mysql v1.9.3
|
||||
go: downloading golang.org/x/net v0.25.0
|
||||
go: downloading github.com/go-playground/validator/v10 v10.20.0
|
||||
go: downloading filippo.io/edwards25519 v1.1.0
|
||||
go: downloading github.com/gabriel-vasile/mimetype v1.4.3
|
||||
go: downloading github.com/go-playground/universal-translator v0.18.1
|
||||
go: downloading github.com/leodido/go-urn v1.4.0
|
||||
go: downloading golang.org/x/crypto v0.23.0
|
||||
go: downloading golang.org/x/text v0.15.0
|
||||
go: downloading github.com/go-playground/locales v0.14.1
|
||||
ok git.yuntuo.hk/k2safe/go-omni 1.086s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/app 1.653s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/auth 1.036s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.124s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 1.133s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/config 1.036s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/database 1.013s
|
||||
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
|
||||
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.156s
|
||||
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.013s
|
||||
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/health 1.041s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.034s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.048s
|
||||
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/logx 1.013s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.034s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.036s
|
||||
? git.yuntuo.hk/k2safe/go-omni/response [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport 1.051s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.551s
|
||||
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.049s
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.035s
|
||||
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.04s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.01s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.27s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
=== RUN TestConfiguredTransportLifecycle
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/nethttp/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/native/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/native/timeout
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/drain
|
||||
=== RUN TestConfiguredTransportLifecycle/http2/timeout
|
||||
--- PASS: TestConfiguredTransportLifecycle (0.28s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/nethttp/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/native/timeout (0.09s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/drain (0.01s)
|
||||
--- PASS: TestConfiguredTransportLifecycle/http2/timeout (0.09s)
|
||||
=== RUN TestConfiguredStartupFailureClosesResources
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/unknown
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2WithoutTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/partialTLS
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nethttpCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/nativeCertificate
|
||||
=== RUN TestConfiguredStartupFailureClosesResources/http2Certificate
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/unknown (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2WithoutTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/partialTLS (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nethttpCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/nativeCertificate (0.00s)
|
||||
--- PASS: TestConfiguredStartupFailureClosesResources/http2Certificate (0.00s)
|
||||
=== RUN TestRunContextConfiguredTLS
|
||||
=== RUN TestRunContextConfiguredTLS/nethttp
|
||||
=== RUN TestRunContextConfiguredTLS/native
|
||||
=== RUN TestRunContextConfiguredTLS/http2
|
||||
--- PASS: TestRunContextConfiguredTLS (0.05s)
|
||||
--- PASS: TestRunContextConfiguredTLS/nethttp (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/native (0.02s)
|
||||
--- PASS: TestRunContextConfiguredTLS/http2 (0.02s)
|
||||
=== RUN TestRunWithRemainsIndependentOfBuiltInTransport
|
||||
--- PASS: TestRunWithRemainsIndependentOfBuiltInTransport (0.00s)
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native
|
||||
=== RUN TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler (0.26s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/nethttp (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/native (0.09s)
|
||||
--- PASS: TestConfiguredShutdownDoesNotWaitForUncooperativeHandler/http2 (0.09s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/app 6.879s
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.09s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
=== RUN TestBusinessConfiguredTransports
|
||||
=== RUN TestBusinessConfiguredTransports/nethttp
|
||||
=== RUN TestBusinessConfiguredTransports/native
|
||||
=== RUN TestBusinessConfiguredTransports/nethttpTLS
|
||||
=== RUN TestBusinessConfiguredTransports/nativeTLS
|
||||
=== RUN TestBusinessConfiguredTransports/http2TLS
|
||||
--- PASS: TestBusinessConfiguredTransports (0.08s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttp (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/native (0.01s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nethttpTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/nativeTLS (0.02s)
|
||||
--- PASS: TestBusinessConfiguredTransports/http2TLS (0.02s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.885s
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
=== RUN TestShutdownDuringResponseWriteClosesNewlyIdleConnection
|
||||
--- PASS: TestShutdownDuringResponseWriteClosesNewlyIdleConnection (0.00s)
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/transport/native 1.058s
|
||||
98
scripts/verifyHTTP2.py
Executable file
98
scripts/verifyHTTP2.py
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the opt-in HTTP/2 TLS soak in a pinned Linux Go image."""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default="golang:1.25")
|
||||
parser.add_argument("--scenario", choices=("single", "mixed"), default="single", help="single TLS connection or eight connections with paced upload/download clients")
|
||||
parser.add_argument("--goproxy", default="", help="optional Go module proxy passed to the container")
|
||||
parser.add_argument("--duration", help="soak duration (default: 30m, or 60s with --race)")
|
||||
parser.add_argument("--timeout", default="35m", help="go test timeout (default: 35m)")
|
||||
parser.add_argument("--gomaxprocs", type=int, default=2)
|
||||
parser.add_argument("--cpus", type=float, default=2, help="container CPU quota (default: 2)")
|
||||
parser.add_argument("--memory-mb", type=int, default=2048, help="container memory cap, with no swap (default: 2048 MiB)")
|
||||
parser.add_argument("--pids-limit", type=int, default=256, help="container process/thread cap (default: 256)")
|
||||
parser.add_argument("--wall-timeout", type=int, default=2400, help="total time limit including build/download, in seconds (default: 2400)")
|
||||
parser.add_argument(
|
||||
"--race",
|
||||
action="store_true",
|
||||
help="enable the race detector for short concurrency checks; its memory overhead is not a production baseline",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.duration is None:
|
||||
args.duration = "60s" if args.race else "30m"
|
||||
if not args.duration or not args.timeout or args.gomaxprocs < 1:
|
||||
parser.error("--duration and --timeout must be non-empty and --gomaxprocs must be positive")
|
||||
if not math.isfinite(args.cpus) or args.cpus <= 0 or args.memory_mb < 128 or args.pids_limit < 16 or args.wall_timeout < 1:
|
||||
parser.error("--cpus and --wall-timeout must be positive; --memory-mb >= 128 and --pids-limit >= 16 are required")
|
||||
|
||||
docker = shutil.which("docker")
|
||||
if docker is None:
|
||||
raise SystemExit("docker is required for HTTP/2 Linux soak")
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
name = "go-omni-http2-soak-" + uuid.uuid4().hex[:12]
|
||||
command = [
|
||||
docker, "run", "--rm", "--pull=missing", "--name", name,
|
||||
"--cpus", str(args.cpus), "--memory", f"{args.memory_mb}m",
|
||||
"--memory-swap", f"{args.memory_mb}m", "--pids-limit", str(args.pids_limit),
|
||||
"--log-opt", "max-size=10m", "--log-opt", "max-file=1",
|
||||
"-v", f"{root}:/workspace:ro", "-w", "/workspace",
|
||||
"-e", "GOWORK=off", "-e", f"GOMAXPROCS={args.gomaxprocs}",
|
||||
"-e", f"GO_OMNI_HTTP2_SOAK_DURATION={args.duration}",
|
||||
]
|
||||
if args.goproxy:
|
||||
command += ["-e", f"GOPROXY={args.goproxy}"]
|
||||
command += [args.image, "go", "test"]
|
||||
if args.race:
|
||||
command.append("-race")
|
||||
command += [
|
||||
"-v", "-timeout", args.timeout, "./transport/http2", "-run",
|
||||
"^TestTLSMixedSoak$" if args.scenario == "mixed" else "^TestTLSSoak$", "-count=1",
|
||||
]
|
||||
print(f"HTTP/2 Linux soak image: {args.image}", flush=True)
|
||||
print(f"HTTP/2 TLS soak duration: {args.duration}", flush=True)
|
||||
print(f"HTTP/2 TLS soak mode: {'race' if args.race else 'regular'}", flush=True)
|
||||
print(f"HTTP/2 TLS soak scenario: {args.scenario}", flush=True)
|
||||
print(f"HTTP/2 soak container: {name}; limits: {args.cpus:g} CPUs, {args.memory_mb} MiB, {args.pids_limit} tasks", flush=True)
|
||||
|
||||
def interrupt(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
previous_handlers = {
|
||||
sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)
|
||||
}
|
||||
try:
|
||||
result = subprocess.run(command, cwd=root, timeout=args.wall_timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit("HTTP/2 Linux soak exceeded --wall-timeout; stopping its container")
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit("HTTP/2 Linux soak interrupted; stopping its container")
|
||||
finally:
|
||||
for sig, handler in previous_handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
# Only this run's unique container is targeted, including on timeout or
|
||||
# a disconnected SSH session. Do not prune other jobs.
|
||||
try:
|
||||
cleanup = subprocess.run(
|
||||
[docker, "rm", "--force", name], capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if cleanup.returncode != 0 and "No such container" not in cleanup.stderr:
|
||||
raise SystemExit(f"Could not clean up {name}: {cleanup.stderr.strip()}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit(f"Cleanup timed out; inspect container {name} before another run")
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"HTTP/2 Linux soak failed with exit code {result.returncode}")
|
||||
print("PASS HTTP/2 TLS soak", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
177
scripts/verifyHTTP2Proxy.py
Normal file
177
scripts/verifyHTTP2Proxy.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify real Nginx TLS HTTP/2 upstreams inside one bounded, isolated container."""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
class ContainerRunner:
|
||||
def __init__(self, docker, output, deadline):
|
||||
self.docker = docker
|
||||
self.output = output
|
||||
self.deadline = deadline
|
||||
self.commands = []
|
||||
self.cleaned = []
|
||||
|
||||
def execute(self, command):
|
||||
remaining = self.deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("HTTP/2 proxy verification exceeded its wall timeout")
|
||||
self.commands.append(command)
|
||||
result = subprocess.run(command, stdout=self.output, stderr=subprocess.STDOUT, timeout=remaining)
|
||||
self.output.flush()
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"command failed with exit code {result.returncode}: {command[1:3]}")
|
||||
|
||||
def container(self, name, arguments):
|
||||
command = [
|
||||
self.docker, "run", "--rm", "--name", name,
|
||||
"--cpus", "2", "--memory", "2048m", "--memory-swap", "2048m", "--pids-limit", "256",
|
||||
"--log-opt", "max-size=10m", "--log-opt", "max-file=1",
|
||||
] + arguments
|
||||
try:
|
||||
self.execute(command)
|
||||
finally:
|
||||
# Always finish cleanup before starting the next stage. Never prune
|
||||
# or target a container belonging to another verification run.
|
||||
cleanup = subprocess.run(
|
||||
[self.docker, "rm", "--force", name], capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if cleanup.returncode and "No such container" not in cleanup.stderr:
|
||||
raise RuntimeError(f"could not clean up {name}: {cleanup.stderr.strip()}")
|
||||
self.cleaned.append(name)
|
||||
|
||||
|
||||
def sourceManifest(root):
|
||||
paths = [root / "go.mod", root / "go.sum", Path(__file__).resolve()]
|
||||
paths += list((root / "transport" / "http2").glob("*.go"))
|
||||
return {str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(paths)}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default="golang:1.25", help="compiler image")
|
||||
parser.add_argument("--nginx-image", help="Nginx image supporting proxy_http_version 2; defaults to nginx:1.30.5-alpine, or nginx:1.30.5 for --race")
|
||||
parser.add_argument("--race", action="store_true", help="instrument the integration test with Go's race detector; requires a glibc-based runtime image")
|
||||
parser.add_argument("--goproxy", default="")
|
||||
parser.add_argument("--count", type=int, default=3, help="repeat the proxy scenarios (default: 3)")
|
||||
parser.add_argument("--wall-timeout", type=int, default=600, help="total pull/build/test deadline in seconds")
|
||||
parser.add_argument("--output", type=Path, default=Path("performance/http2ProxyLinux.json"))
|
||||
args = parser.parse_args()
|
||||
if args.nginx_image is None:
|
||||
args.nginx_image = "nginx:1.30.5" if args.race else "nginx:1.30.5-alpine"
|
||||
if args.count < 1 or args.count > 10 or args.wall_timeout < 1:
|
||||
parser.error("--count must be 1..10 and --wall-timeout must be positive")
|
||||
docker = shutil.which("docker")
|
||||
if not docker:
|
||||
raise SystemExit("docker is required")
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
args.output = args.output.resolve()
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
logPath = args.output.with_suffix(".txt")
|
||||
runID = uuid.uuid4().hex[:12]
|
||||
names = [f"go-omni-http2-proxy-{runID}-{stage}" for stage in ("build", "check")]
|
||||
capture = lambda command: subprocess.check_output(command, text=True, timeout=15).strip()
|
||||
running = capture([docker, "ps", "--format", "{{.ID}} {{.Names}}"])
|
||||
if any("go-omni-http2-" in row for row in running.splitlines()):
|
||||
raise SystemExit("another HTTP/2 validation container is running; wait for it to finish")
|
||||
before = {row.split()[0] for row in running.splitlines()}
|
||||
report = {
|
||||
"status": "running", "startedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"testedCommit": capture(["git", "-C", str(root), "rev-parse", "HEAD"]),
|
||||
"workingTreeStatus": capture(["git", "-C", str(root), "status", "--porcelain"]),
|
||||
"sourceSha256": sourceManifest(root), "repetitions": args.count, "race": args.race,
|
||||
"containerLimits": {"cpus": 2, "memoryBytes": 2147483648, "memoryAndSwapBytes": 2147483648, "pidsLimit": 256},
|
||||
"wallTimeoutSeconds": args.wall_timeout, "imageIds": {},
|
||||
"notes": [
|
||||
"Compiler and integration stages run sequentially; at most one 2-CPU/2-GiB validation container is active.",
|
||||
"The integration container has network=none, no published ports and a read-only root filesystem. Client, Nginx and origins communicate over loopback inside that container.",
|
||||
"Both TLS legs validate certificates; invalid upstream CA and hostname must fail before reaching the handler.",
|
||||
"HTTP/2 is verified independently on both legs. This is a functional integration test, not a throughput benchmark or a live Cloudflare deployment.",
|
||||
],
|
||||
}
|
||||
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
def interrupt(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
handlers = {sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)}
|
||||
failure = None
|
||||
with logPath.open("w") as output:
|
||||
runner = ContainerRunner(docker, output, time.monotonic() + args.wall_timeout)
|
||||
try:
|
||||
for imageName in (args.image, args.nginx_image):
|
||||
inspected = subprocess.run([docker, "image", "inspect", imageName, "--format", "{{.Id}}"], capture_output=True, text=True, timeout=15)
|
||||
if inspected.returncode:
|
||||
print(f"Pulling {imageName}", flush=True)
|
||||
runner.execute([docker, "pull", imageName])
|
||||
report["imageIds"][imageName] = capture([docker, "image", "inspect", imageName, "--format", "{{.Id}}"])
|
||||
with tempfile.TemporaryDirectory(prefix="goOmniHTTP2Proxy") as temporary:
|
||||
print("Building proxy integration tests; limits: 2 CPUs, 2048 MiB, 256 tasks", flush=True)
|
||||
arguments = [
|
||||
"-v", f"{root}:/workspace:ro", "-v", f"{temporary}:/output", "-w", "/workspace",
|
||||
"-e", "GOWORK=off", "-e", "GOMAXPROCS=2", "-e", f"CGO_ENABLED={1 if args.race else 0}",
|
||||
]
|
||||
if args.goproxy:
|
||||
arguments += ["-e", f"GOPROXY={args.goproxy}"]
|
||||
raceFlag = "-race " if args.race else ""
|
||||
arguments += [args.image, "sh", "-c", f"go version && go vet ./transport/http2 && go test {raceFlag}-c -o /output/http2.test ./transport/http2"]
|
||||
runner.container(names[0], arguments)
|
||||
print(f"Running Nginx HTTP/2 proxy scenarios, {args.count} repetitions; external networking disabled", flush=True)
|
||||
runner.container(names[1], [
|
||||
"--network", "none", "--read-only", "--tmpfs", "/tmp:rw,nosuid,noexec,size=128m",
|
||||
"-v", f"{temporary}:/test:ro", "--entrypoint", "/test/http2.test",
|
||||
"-e", "GO_OMNI_HTTP2_PROXY_TEST=1", "-e", "GOMAXPROCS=2",
|
||||
args.nginx_image, "-test.v", "-test.run", "^TestNginxHTTP2Proxy$",
|
||||
"-test.timeout", "120s", "-test.count", str(args.count),
|
||||
])
|
||||
log = logPath.read_text()
|
||||
for case in ("TLSAndConnectionReuse", "TimeoutAndCancellation", "GoAwayAndRollingDrain"):
|
||||
if len(re.findall(r"--- PASS: TestNginxHTTP2Proxy/" + case + r" \(", log)) != args.count:
|
||||
raise RuntimeError("missing successful integration case: " + case)
|
||||
report["proxyVersions"] = sorted(set(re.findall(r"proxyVersion=(.+)", log)))
|
||||
report["results"] = re.findall(r"proxyResult=(.+)", log)
|
||||
version = re.search(r"^go version (\S+) (\S+)$", log, re.MULTILINE)
|
||||
if version is None or not report["proxyVersions"]:
|
||||
raise RuntimeError("missing Go or Nginx version evidence")
|
||||
report["goVersion"], report["platform"] = version.groups()
|
||||
report["status"] = "passed"
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
failure = str(error) or type(error).__name__
|
||||
report["status"], report["error"] = "failed", failure
|
||||
finally:
|
||||
for sig, handler in handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
report["commands"] = runner.commands
|
||||
report["cleanedContainers"] = runner.cleaned
|
||||
report["finishedAt"] = datetime.now(timezone.utc).isoformat()
|
||||
output.flush()
|
||||
report["logSha256"] = hashlib.sha256(logPath.read_bytes()).hexdigest()
|
||||
try:
|
||||
after = set(capture([docker, "ps", "--format", "{{.ID}}"]).split())
|
||||
if not before.issubset(after):
|
||||
raise RuntimeError("a pre-existing container is no longer running")
|
||||
report["preExistingContainersStillRunning"] = len(before)
|
||||
if set(runner.cleaned) != set(names) and failure is None:
|
||||
raise RuntimeError("container cleanup was not confirmed")
|
||||
except Exception as error:
|
||||
failure = str(error)
|
||||
report["status"], report["error"] = "failed", failure
|
||||
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
||||
if failure:
|
||||
raise SystemExit(f"FAIL HTTP/2 proxy verification: {failure}; see {logPath}")
|
||||
print(f"PASS HTTP/2 Nginx proxy verification; report: {args.output}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
59
scripts/verifyHTTP2Proxy_test.py
Normal file
59
scripts/verifyHTTP2Proxy_test.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Keep proxy integration failures and interrupts inside their resource envelope."""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("proxyRunner", Path(__file__).with_name("verifyHTTP2Proxy.py"))
|
||||
runnerModule = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(runnerModule)
|
||||
|
||||
|
||||
class ContainerCleanupTests(unittest.TestCase):
|
||||
def testFailedAndInterruptedStagesRemoveOnlyTheirContainer(self):
|
||||
failures = (subprocess.TimeoutExpired("docker", 1), KeyboardInterrupt(), subprocess.CompletedProcess([], 7))
|
||||
for failure in failures:
|
||||
with self.subTest(failure=type(failure).__name__):
|
||||
calls = []
|
||||
|
||||
def execute(command, **kwargs):
|
||||
calls.append(command)
|
||||
if command[1] == "run":
|
||||
if isinstance(failure, BaseException):
|
||||
raise failure
|
||||
return failure
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
runner = runnerModule.ContainerRunner("/fake/docker", io.StringIO(), time.monotonic() + 10)
|
||||
with patch.object(runnerModule.subprocess, "run", side_effect=execute):
|
||||
with self.assertRaises((subprocess.TimeoutExpired, KeyboardInterrupt, RuntimeError)):
|
||||
runner.container("owned-proxy-check", ["nginx:test"])
|
||||
self.assertEqual(calls[1:], [["/fake/docker", "rm", "--force", "owned-proxy-check"]])
|
||||
self.assertEqual(runner.cleaned, ["owned-proxy-check"])
|
||||
|
||||
def testCleanupFailureRemainsAnError(self):
|
||||
runner = runnerModule.ContainerRunner("/fake/docker", io.StringIO(), time.monotonic() + 10)
|
||||
with patch.object(runnerModule.subprocess, "run", side_effect=[
|
||||
subprocess.CompletedProcess([], 0),
|
||||
subprocess.CompletedProcess([], 1, "", "Docker daemon unavailable"),
|
||||
]):
|
||||
with self.assertRaisesRegex(RuntimeError, "could not clean up"):
|
||||
runner.container("owned-proxy-check", ["nginx:test"])
|
||||
self.assertEqual(runner.cleaned, [])
|
||||
|
||||
def testDeadlinePreventsStartingAnotherContainer(self):
|
||||
runner = runnerModule.ContainerRunner("/fake/docker", io.StringIO(), time.monotonic() - 1)
|
||||
with patch.object(runnerModule.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "", "")) as execute:
|
||||
with self.assertRaises(TimeoutError):
|
||||
runner.container("not-started", ["nginx:test"])
|
||||
self.assertEqual(execute.call_args.args[0], ["/fake/docker", "rm", "--force", "not-started"])
|
||||
self.assertEqual(execute.call_count, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
65
scripts/verifyHTTP2_test.py
Normal file
65
scripts/verifyHTTP2_test.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Regression checks for stress-container cleanup on failure and interruption."""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("http2Runner", Path(__file__).with_name("verifyHTTP2.py"))
|
||||
runner = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(runner)
|
||||
|
||||
|
||||
class CleanupTests(unittest.TestCase):
|
||||
def testFailedRunsCleanUpOnlyTheirOwnContainer(self):
|
||||
failures = (
|
||||
subprocess.TimeoutExpired("docker", 1),
|
||||
KeyboardInterrupt(),
|
||||
subprocess.CompletedProcess([], 7),
|
||||
)
|
||||
for failure in failures:
|
||||
with self.subTest(failure=type(failure).__name__):
|
||||
calls = []
|
||||
original_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGHUP)}
|
||||
|
||||
def execute(command, **kwargs):
|
||||
calls.append(command)
|
||||
if command[1] == "run":
|
||||
if isinstance(failure, BaseException):
|
||||
raise failure
|
||||
return failure
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
with patch.object(sys, "argv", ["verifyHTTP2.py", "--duration", "1s"]), \
|
||||
patch.object(runner.shutil, "which", return_value="/fake/docker"), \
|
||||
patch.object(runner.subprocess, "run", side_effect=execute), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
runner.main()
|
||||
self.assertNotEqual(raised.exception.code, 0)
|
||||
name = calls[0][calls[0].index("--name") + 1]
|
||||
self.assertEqual(calls[1:], [["/fake/docker", "rm", "--force", name]])
|
||||
self.assertEqual(original_handlers, {sig: signal.getsignal(sig) for sig in original_handlers})
|
||||
|
||||
def testCleanupFailureIsNotReportedAsPass(self):
|
||||
results = [
|
||||
subprocess.CompletedProcess([], 0),
|
||||
subprocess.CompletedProcess([], 1, "", "Cannot connect to the Docker daemon"),
|
||||
]
|
||||
with patch.object(sys, "argv", ["verifyHTTP2.py", "--duration", "1s"]), \
|
||||
patch.object(runner.shutil, "which", return_value="/fake/docker"), \
|
||||
patch.object(runner.subprocess, "run", side_effect=results), \
|
||||
contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
with self.assertRaisesRegex(SystemExit, "Could not clean up"):
|
||||
runner.main()
|
||||
self.assertNotIn("PASS", output.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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)
|
||||
|
||||
157
scripts/verifyModules.py
Normal file
157
scripts/verifyModules.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify feature modules and real Redis in sequential resource-limited containers."""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
def capture(command):
|
||||
return subprocess.check_output(command, text=True, timeout=15).strip()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default="golang:1.25")
|
||||
parser.add_argument("--redis-image", default="redis:7.4-alpine")
|
||||
parser.add_argument("--goproxy", default="")
|
||||
parser.add_argument("--wall-timeout", type=int, default=900)
|
||||
parser.add_argument("--output", type=Path, default=Path("performance/modulesLinux.json"))
|
||||
args = parser.parse_args()
|
||||
if args.wall_timeout < 1:
|
||||
parser.error("wall timeout must be positive")
|
||||
docker = shutil.which("docker")
|
||||
if not docker:
|
||||
raise SystemExit("docker is required")
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
running = capture([docker, "ps", "--format", "{{.ID}} {{.Names}}"])
|
||||
if any("go-omni-" in line for line in running.splitlines()):
|
||||
raise SystemExit("another go-omni validation container is active")
|
||||
before = {line.split()[0] for line in running.splitlines()}
|
||||
output_path = args.output.resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_path = output_path.with_suffix(".txt")
|
||||
prefix = "go-omni-modules-" + uuid.uuid4().hex[:12]
|
||||
names = [prefix + "-" + stage for stage in ("build", "redis")]
|
||||
paths = [root / "go.mod", root / "go.sum", root / "httpx/context.go", Path(__file__).resolve()]
|
||||
for directory in ("cache", "httpc", "task", "middleware", "cmd/go-omni", "examples/modules"):
|
||||
paths.extend(p for p in (root / directory).rglob("*") if p.is_file() and p.suffix in (".go", ".tmpl"))
|
||||
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": {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(set(paths))},
|
||||
"containerLimits": {"cpus": 2, "memoryBytes": 2147483648, "memoryAndSwapBytes": 2147483648, "pidsLimit": 256},
|
||||
"wallTimeoutSeconds": args.wall_timeout, "imageIds": {}, "commands": [], "cleanedContainers": [],
|
||||
"notes": [
|
||||
"Full repository race/vet/build plus independent generated-module CRUD in the compiler container.",
|
||||
"Real Redis uses a statically linked non-race test binary, loopback only, network=none and no published ports.",
|
||||
"At most one validation container; no pre-existing service or database is accessed.",
|
||||
],
|
||||
}
|
||||
output_path.write_text(json.dumps(report, indent=2) + "\n")
|
||||
deadline = time.monotonic() + args.wall_timeout
|
||||
failure = None
|
||||
|
||||
def interrupt(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
previous = {sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)}
|
||||
with log_path.open("w") as log:
|
||||
def execute(command):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("module verification wall timeout")
|
||||
report["commands"].append(command)
|
||||
subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=remaining)
|
||||
log.flush()
|
||||
|
||||
def container(name, arguments):
|
||||
try:
|
||||
execute([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)
|
||||
finally:
|
||||
result = subprocess.run([docker, "rm", "--force", name], capture_output=True, text=True, timeout=15)
|
||||
if result.returncode and "No such container" not in result.stderr:
|
||||
raise RuntimeError("could not clean up " + name)
|
||||
report["cleanedContainers"].append(name)
|
||||
|
||||
try:
|
||||
for image in (args.image, args.redis_image):
|
||||
result = subprocess.run([docker, "image", "inspect", image], capture_output=True, timeout=15)
|
||||
if result.returncode:
|
||||
print("Pulling " + image, flush=True)
|
||||
execute([docker, "pull", image])
|
||||
report["imageIds"][image] = capture([docker, "image", "inspect", image, "--format", "{{.Id}}"])
|
||||
with tempfile.TemporaryDirectory(prefix="goOmniModules") as temporary:
|
||||
# The Redis stage runs as an unprivileged uid and must traverse
|
||||
# this directory to execute the read-only test binary.
|
||||
Path(temporary).chmod(0o755)
|
||||
print("Full Linux race/vet/build and generated-project CRUD; 2 CPUs, 2048 MiB, 256 pids", flush=True)
|
||||
arguments = ["-v", f"{root}:/workspace:ro", "-v", f"{temporary}:/output", "-w", "/workspace",
|
||||
"-e", "GOWORK=off", "-e", "GOMAXPROCS=2", "-e", "GO_OMNI_SCAFFOLD_INTEGRATION=1"]
|
||||
if args.goproxy:
|
||||
arguments += ["-e", "GOPROXY=" + args.goproxy]
|
||||
arguments += [args.image, "sh", "-c",
|
||||
"go version && go test -race -count=1 -timeout=240s ./... && go vet ./... && go build ./... && "
|
||||
"CGO_ENABLED=0 go test -c -o /output/redis.test ./cache/redis"]
|
||||
container(names[0], arguments)
|
||||
print("Real Redis TTL/read/write/delete/failure checks; external network disabled", flush=True)
|
||||
redis_check = '''set -eu
|
||||
redis-server --version
|
||||
redis-server --bind 127.0.0.1 --port 6379 --save '' --appendonly no --maxmemory 32mb --daemonize yes --pidfile /tmp/redis.pid --logfile /tmp/redis.log --dir /tmp
|
||||
trap 'redis-cli -h 127.0.0.1 shutdown nosave >/dev/null 2>&1 || true' EXIT
|
||||
attempt=0
|
||||
until redis-cli -h 127.0.0.1 ping >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -gt 50 ]; then cat /tmp/redis.log; exit 1; fi
|
||||
sleep 0.1
|
||||
done
|
||||
/test/redis.test -test.v -test.run '^TestRedisIntegration$' -test.count=1 -test.timeout=30s
|
||||
'''
|
||||
container(names[1], ["--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
||||
"--user", "999:999", "--tmpfs", "/tmp:rw,nosuid,noexec,size=64m,mode=1777",
|
||||
"-v", f"{temporary}:/test:ro", "--entrypoint", "sh",
|
||||
"-e", "GOMAXPROCS=2", "-e", "GO_OMNI_REDIS_TEST_ADDR=127.0.0.1:6379",
|
||||
args.redis_image, "-c", redis_check])
|
||||
log.flush()
|
||||
content = log_path.read_text()
|
||||
if "--- PASS: TestRedisIntegration" not in content:
|
||||
raise RuntimeError("missing real Redis pass evidence")
|
||||
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 previous.items():
|
||||
signal.signal(sig, handler)
|
||||
log.flush()
|
||||
report["logSha256"] = hashlib.sha256(log_path.read_bytes()).hexdigest()
|
||||
report["finishedAt"] = datetime.now(timezone.utc).isoformat()
|
||||
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 failure is None and set(names) != set(report["cleanedContainers"]):
|
||||
raise RuntimeError("container cleanup incomplete")
|
||||
except Exception as error:
|
||||
failure = str(error)
|
||||
report["status"], report["error"] = "failed", failure
|
||||
output_path.write_text(json.dumps(report, indent=2) + "\n")
|
||||
if failure:
|
||||
raise SystemExit(f"FAIL: {failure}; see {log_path}")
|
||||
print(f"PASS feature module acceptance; report: {output_path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
313
scripts/verifyProxy.py
Normal file
313
scripts/verifyProxy.py
Normal file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify an isolated Nginx -> native TLS reverse-proxy deployment on Linux."""
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def wait_for_native(process: subprocess.Popen[str], timeout: float) -> int:
|
||||
selector = selectors.DefaultSelector()
|
||||
assert process.stdout is not None
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"native server exited with code {process.returncode}")
|
||||
for _, _ in selector.select(timeout=0.5):
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
address = value.get("address", "")
|
||||
if address:
|
||||
return int(address.rsplit(":", 1)[1])
|
||||
raise RuntimeError("native TLS server did not announce a listener")
|
||||
finally:
|
||||
selector.close()
|
||||
|
||||
|
||||
def write_edge_certificate(directory: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", str(directory / "key.pem"),
|
||||
"-out", str(directory / "cert.pem"),
|
||||
"-days", "1", "-subj", "/CN=localhost",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def write_nginx_config(path: Path, backend_port: int, edge_port: int) -> None:
|
||||
path.write_text(
|
||||
f"""events {{
|
||||
worker_connections 512;
|
||||
}}
|
||||
|
||||
http {{
|
||||
upstream go_omni_native {{
|
||||
server 127.0.0.1:{backend_port};
|
||||
keepalive 64;
|
||||
}}
|
||||
|
||||
server {{
|
||||
listen 127.0.0.1:{edge_port} ssl;
|
||||
http2 on;
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Request-ID $http_x_request_id;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 1s;
|
||||
proxy_read_timeout 300ms;
|
||||
proxy_send_timeout 1s;
|
||||
|
||||
location / {{
|
||||
proxy_pass https://go_omni_native;
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name off;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def request(connection: http.client.HTTPSConnection, path: str) -> tuple[int, dict[str, str], bytes]:
|
||||
connection.request("GET", path, headers={"X-Request-ID": "proxyCheck"})
|
||||
response = connection.getresponse()
|
||||
body = response.read()
|
||||
return response.status, dict(response.headers), body
|
||||
|
||||
|
||||
def header(headers: dict[str, str], name: str) -> str:
|
||||
for key, value in headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def verify_keep_alive(edge_port: int) -> None:
|
||||
context = ssl._create_unverified_context()
|
||||
connection = http.client.HTTPSConnection("127.0.0.1", edge_port, context=context, timeout=3)
|
||||
expected = b'{"code":"OK","message":"success","data":{"status":"ok"},"requestId":"proxyCheck"}\n'
|
||||
try:
|
||||
for attempt in range(5):
|
||||
status, headers, body = request(connection, "/health")
|
||||
if status != 200 or header(headers, "X-Request-ID") != "proxyCheck" or body != expected:
|
||||
raise AssertionError(f"unexpected proxied response: status={status} body={body!r}")
|
||||
if attempt < 4 and getattr(connection, "sock", None) is None:
|
||||
raise AssertionError("Nginx did not keep the TLS client connection alive")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def wait_for_proxy(edge_port: int, timeout: float) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error = "proxy did not return a healthy response"
|
||||
while time.monotonic() < deadline:
|
||||
connection = None
|
||||
try:
|
||||
context = ssl._create_unverified_context()
|
||||
connection = http.client.HTTPSConnection("127.0.0.1", edge_port, context=context, timeout=2)
|
||||
status, headers, body = request(connection, "/health")
|
||||
if status == 200 and header(headers, "X-Request-ID") == "proxyCheck" and body.endswith(b"\n"):
|
||||
return
|
||||
last_error = f"status={status} body={body!r}"
|
||||
except (OSError, http.client.HTTPException) as error:
|
||||
last_error = str(error)
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"Nginx proxy did not become healthy: {last_error}")
|
||||
|
||||
|
||||
def verify_http2(edge_port: int) -> None:
|
||||
expected = b'{"code":"OK","message":"success","data":{"status":"ok"},"requestId":"proxyCheck"}\n'
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl", "--silent", "--show-error", "--fail", "--http2", "--insecure",
|
||||
"-H", "X-Request-ID: proxyCheck", "-w", "\\n%{http_version}",
|
||||
f"https://127.0.0.1:{edge_port}/health",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(f"HTTP/2 curl failed: {result.stderr.decode(errors='replace')}")
|
||||
output = result.stdout
|
||||
try:
|
||||
body, version = output.rsplit(b"\n", 1)
|
||||
except ValueError as error:
|
||||
raise AssertionError(f"HTTP/2 curl output missing protocol version: {output!r}") from error
|
||||
if version != b"2" or body != expected:
|
||||
raise AssertionError(f"expected HTTP/2 response, got version={version!r} body={body!r}")
|
||||
|
||||
|
||||
def verify_timeout(edge_port: int) -> None:
|
||||
context = ssl._create_unverified_context()
|
||||
connection = http.client.HTTPSConnection("127.0.0.1", edge_port, context=context, timeout=3)
|
||||
try:
|
||||
status, _, _ = request(connection, "/slow")
|
||||
if status != 504:
|
||||
raise AssertionError(f"expected Nginx upstream timeout 504, got {status}")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default="golang:1.25")
|
||||
parser.add_argument("--nginx-image", default="nginx:1.27-alpine")
|
||||
parser.add_argument("--goproxy", default="", help="optional Go module proxy passed to the backend container")
|
||||
args = parser.parse_args()
|
||||
if shutil.which("docker") is None:
|
||||
raise SystemExit("docker is required for proxy verification")
|
||||
if shutil.which("openssl") is None:
|
||||
raise SystemExit("openssl is required for proxy verification")
|
||||
if shutil.which("curl") is None:
|
||||
raise SystemExit("curl with HTTP/2 support is required for proxy verification")
|
||||
if not Path("/proc").exists():
|
||||
raise SystemExit("proxy verification must run on Linux")
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
backend_name = "go-omni-proxy-native"
|
||||
nginx_name = "go-omni-proxy-nginx"
|
||||
backend = None
|
||||
nginx = None
|
||||
with tempfile.TemporaryDirectory(prefix="go-omni-proxy-") as temporary:
|
||||
directory = Path(temporary)
|
||||
edge_port = free_port()
|
||||
write_edge_certificate(directory)
|
||||
backend_command = [
|
||||
"docker", "run", "--rm", "-i", "--pull=missing", "--name", backend_name,
|
||||
"--network", "host", "--cpus=1", "--memory=512m", "--pids-limit=512",
|
||||
"-v", f"{root}:/workspace", "-w", "/workspace", "-e", "GOWORK=off",
|
||||
]
|
||||
if args.goproxy:
|
||||
backend_command += ["-e", f"GOPROXY={args.goproxy}"]
|
||||
backend_command += [args.image, "sh", "-c", "go run ./internal/perfcheck --server native --tls"]
|
||||
backend = subprocess.Popen(
|
||||
backend_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
try:
|
||||
backend_port = wait_for_native(backend, 120)
|
||||
write_nginx_config(directory / "nginx.conf", backend_port, edge_port)
|
||||
print(f"proxy backend=127.0.0.1:{backend_port} edge=127.0.0.1:{edge_port}", flush=True)
|
||||
nginx = subprocess.Popen(
|
||||
[
|
||||
"docker", "run", "--rm", "--pull=missing", "--name", nginx_name,
|
||||
"--network", "host",
|
||||
"-v", f"{directory / 'nginx.conf'}:/etc/nginx/nginx.conf:ro",
|
||||
"-v", f"{directory}:/etc/nginx/certs:ro",
|
||||
args.nginx_image, "nginx", "-g", "daemon off;",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
if nginx.poll() is not None:
|
||||
raise RuntimeError(f"Nginx exited with code {nginx.returncode}")
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", edge_port), timeout=0.5):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
nginx_logs = subprocess.run(
|
||||
["docker", "logs", nginx_name], capture_output=True, text=True, check=False
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Nginx did not start listening; logs="
|
||||
f"{(nginx_logs.stdout + nginx_logs.stderr)[-4000:]!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
wait_for_proxy(edge_port, 30)
|
||||
except Exception as error:
|
||||
nginx_logs = subprocess.run(
|
||||
["docker", "logs", nginx_name], capture_output=True, text=True, check=False
|
||||
).stdout[-2000:]
|
||||
backend_logs = subprocess.run(
|
||||
["docker", "logs", backend_name], capture_output=True, text=True, check=False
|
||||
).stdout[-2000:]
|
||||
raise RuntimeError(
|
||||
f"{error}; nginx logs={nginx_logs!r}; backend logs={backend_logs!r}"
|
||||
) from error
|
||||
verify_http2(edge_port)
|
||||
print("PASS Nginx HTTP/2 edge negotiation and HTTP/1.1 native backhaul", flush=True)
|
||||
verify_keep_alive(edge_port)
|
||||
verify_timeout(edge_port)
|
||||
print("PASS Nginx TLS proxy, Keep-Alive and upstream timeout", flush=True)
|
||||
|
||||
assert backend.stdin is not None
|
||||
assert backend.stdout is not None
|
||||
backend.stdin.write('"snapshot"\n')
|
||||
backend.stdin.flush()
|
||||
print(f"native after proxy timeout: {backend.stdout.readline().strip()}", flush=True)
|
||||
backend.stdin.write('"stop"\n')
|
||||
backend.stdin.flush()
|
||||
exit_code = backend.wait(timeout=5)
|
||||
if exit_code != 0:
|
||||
details = backend.stderr.read().strip() if backend.stderr is not None else ""
|
||||
raise AssertionError(
|
||||
f"native server did not shut down cleanly behind Nginx: exit={exit_code} {details}"
|
||||
)
|
||||
print("PASS native graceful shutdown behind proxy", flush=True)
|
||||
finally:
|
||||
if nginx is not None and nginx.poll() is None:
|
||||
nginx.terminate()
|
||||
try:
|
||||
nginx.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
nginx.kill()
|
||||
nginx.wait(timeout=5)
|
||||
if backend is not None and backend.poll() is None:
|
||||
if backend.stdin is not None:
|
||||
try:
|
||||
backend.stdin.write('"stop"\n')
|
||||
backend.stdin.flush()
|
||||
except OSError:
|
||||
pass
|
||||
backend.terminate()
|
||||
try:
|
||||
backend.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
backend.kill()
|
||||
backend.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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__":
|
||||
|
||||
262
task/task.go
Normal file
262
task/task.go
Normal file
@@ -0,0 +1,262 @@
|
||||
// Package task provides a bounded in-process worker pool and interval jobs.
|
||||
// Jobs are not durable and are not coordinated across service replicas.
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
var ErrClosed = errors.New("task: closed")
|
||||
var ErrQueueFull = errors.New("task: queue full")
|
||||
|
||||
type Func func(context.Context) error
|
||||
type Config struct {
|
||||
Workers int `json:"workers"`
|
||||
QueueSize int `json:"queueSize"`
|
||||
MaxSchedules int `json:"maxSchedules"`
|
||||
TaskTimeout time.Duration `json:"taskTimeout"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
OnError func(string, error) `json:"-"`
|
||||
}
|
||||
|
||||
type Future struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *Future) Wait(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("task: nil context")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-f.done:
|
||||
return f.err
|
||||
}
|
||||
}
|
||||
func (f *Future) Done() <-chan struct{} { return f.done }
|
||||
|
||||
type job struct {
|
||||
ctx context.Context
|
||||
name string
|
||||
fn Func
|
||||
future *Future
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
config Config
|
||||
mu sync.Mutex
|
||||
queue chan job
|
||||
closed bool
|
||||
schedules map[string]struct{}
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
active atomic.Int64
|
||||
completed atomic.Uint64
|
||||
failed atomic.Uint64
|
||||
skipped atomic.Uint64
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Active int64 `json:"active"`
|
||||
Queued int `json:"queued"`
|
||||
Completed uint64 `json:"completed"`
|
||||
Failed uint64 `json:"failed"`
|
||||
Skipped uint64 `json:"skipped"`
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Runner, error) {
|
||||
if cfg.Workers < 0 || cfg.QueueSize < 0 || cfg.MaxSchedules < 0 || cfg.TaskTimeout < 0 || cfg.ShutdownTimeout < 0 {
|
||||
return nil, errors.New("task: invalid configuration")
|
||||
}
|
||||
if cfg.Workers == 0 {
|
||||
cfg.Workers = 4
|
||||
}
|
||||
if cfg.QueueSize == 0 {
|
||||
cfg.QueueSize = 128
|
||||
}
|
||||
if cfg.MaxSchedules == 0 {
|
||||
cfg.MaxSchedules = 128
|
||||
}
|
||||
if cfg.TaskTimeout == 0 {
|
||||
cfg.TaskTimeout = 30 * time.Second
|
||||
}
|
||||
if cfg.ShutdownTimeout == 0 {
|
||||
cfg.ShutdownTimeout = 10 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := &Runner{config: cfg, queue: make(chan job, cfg.QueueSize), schedules: make(map[string]struct{}), stop: make(chan struct{}), done: make(chan struct{}), ctx: ctx, cancel: cancel}
|
||||
r.wg.Add(cfg.Workers)
|
||||
for i := 0; i < cfg.Workers; i++ {
|
||||
go r.worker()
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Submit never blocks on a full queue. The job retains the caller's context;
|
||||
// use a service-owned context for work intended to outlive an HTTP request.
|
||||
func (r *Runner) Submit(ctx context.Context, name string, fn Func) (*Future, error) {
|
||||
if ctx == nil || fn == nil || name == "" || len(name) > 256 {
|
||||
return nil, errors.New("task: context, function and bounded name are required")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
f := &Future{done: make(chan struct{})}
|
||||
select {
|
||||
case r.queue <- job{ctx: ctx, name: name, fn: fn, future: f}:
|
||||
return f, nil
|
||||
default:
|
||||
return nil, ErrQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
// Every schedules at a fixed interval, with no initial execution. A tick is
|
||||
// skipped if this schedule's previous job is queued/running or the pool is full.
|
||||
func (r *Runner) Every(name string, interval time.Duration, fn Func) error {
|
||||
if name == "" || len(name) > 256 || interval <= 0 || fn == nil {
|
||||
return errors.New("task: invalid schedule")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
return ErrClosed
|
||||
}
|
||||
if _, ok := r.schedules[name]; ok {
|
||||
return errors.New("task: duplicate schedule name")
|
||||
}
|
||||
if len(r.schedules) >= r.config.MaxSchedules {
|
||||
return errors.New("task: schedule capacity reached")
|
||||
}
|
||||
r.schedules[name] = struct{}{}
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
var previous *Future
|
||||
for {
|
||||
select {
|
||||
case <-r.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if previous != nil {
|
||||
select {
|
||||
case <-previous.Done():
|
||||
previous = nil
|
||||
default:
|
||||
r.skipped.Add(1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
var err error
|
||||
previous, err = r.Submit(r.ctx, name, fn)
|
||||
if err != nil {
|
||||
r.skipped.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) worker() {
|
||||
defer r.wg.Done()
|
||||
for item := range r.queue {
|
||||
r.active.Add(1)
|
||||
ctx, cancel := context.WithTimeout(item.ctx, r.config.TaskTimeout)
|
||||
stop := context.AfterFunc(r.ctx, cancel)
|
||||
if r.ctx.Err() != nil {
|
||||
cancel()
|
||||
}
|
||||
err := run(ctx, item.fn)
|
||||
// AfterFunc cancellation may still be queued when the job returns.
|
||||
// Check the pool directly before publishing a successful outcome.
|
||||
if err == nil {
|
||||
err = r.ctx.Err()
|
||||
}
|
||||
stop()
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.failed.Add(1)
|
||||
func() {
|
||||
defer func() { _ = recover() }()
|
||||
if r.config.OnError != nil {
|
||||
r.config.OnError(item.name, err)
|
||||
} else {
|
||||
logx.ErrorContext(item.ctx, "task.failed", "task", item.name, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
r.active.Add(-1)
|
||||
r.completed.Add(1)
|
||||
item.future.err = err
|
||||
close(item.future.done)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, fn Func) (err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
err = fmt.Errorf("task: panic: %v", value)
|
||||
}
|
||||
}()
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = fn(ctx)
|
||||
if err == nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Shutdown stops schedules and submission, then drains accepted jobs. Deadline
|
||||
// expiry cancels running/queued jobs and returns without waiting for code that
|
||||
// ignores cancellation. Such code cannot be forcibly stopped by Go.
|
||||
func (r *Runner) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("task: nil context")
|
||||
}
|
||||
r.mu.Lock()
|
||||
if !r.closed {
|
||||
r.closed = true
|
||||
close(r.stop)
|
||||
close(r.queue)
|
||||
go func() { r.wg.Wait(); r.cancel(); close(r.done) }()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-r.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
r.cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) Close() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout)
|
||||
defer cancel()
|
||||
return r.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (r *Runner) Stats() Stats {
|
||||
return Stats{Active: r.active.Load(), Queued: len(r.queue), Completed: r.completed.Load(), Failed: r.failed.Load(), Skipped: r.skipped.Load()}
|
||||
}
|
||||
102
task/task_test.go
Normal file
102
task/task_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCapacityDrainPanicAndClose(t *testing.T) {
|
||||
r, err := New(Config{Workers: 1, QueueSize: 1, OnError: func(string, error) { panic("callback failure") }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
first, _ := r.Submit(context.Background(), "first", func(context.Context) error { close(started); <-release; return nil })
|
||||
<-started
|
||||
second, _ := r.Submit(context.Background(), "panic", func(context.Context) error { panic("job failure") })
|
||||
if _, err := r.Submit(context.Background(), "overflow", func(context.Context) error { return nil }); !errors.Is(err, ErrQueueFull) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- r.Close() }()
|
||||
close(release)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := first.Wait(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.Wait(ctx); err == nil {
|
||||
t.Fatal("panic lost")
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := r.Stats(); s.Completed != 2 || s.Failed != 1 || s.Active != 0 || s.Queued != 0 {
|
||||
t.Fatal(s)
|
||||
}
|
||||
if _, err := r.Submit(ctx, "late", func(context.Context) error { return nil }); !errors.Is(err, ErrClosed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownDeadlineAndQueuedCancellation(t *testing.T) {
|
||||
r, _ := New(Config{Workers: 1, QueueSize: 1, ShutdownTimeout: 20 * time.Millisecond, OnError: func(string, error) {}})
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
var queued atomic.Bool
|
||||
first, _ := r.Submit(context.Background(), "ignoresCancel", func(context.Context) error { close(started); <-release; return nil })
|
||||
<-started
|
||||
second, _ := r.Submit(context.Background(), "queued", func(context.Context) error { queued.Store(true); return nil })
|
||||
if err := r.Close(); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(release)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := first.Wait(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.Wait(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued.Load() {
|
||||
t.Fatal("ran queued work after forced shutdown")
|
||||
}
|
||||
if err := r.Shutdown(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntervalsDoNotOverlapAndTaskDeadline(t *testing.T) {
|
||||
r, _ := New(Config{Workers: 2, TaskTimeout: 25 * time.Millisecond, OnError: func(string, error) {}})
|
||||
defer r.Close()
|
||||
var active, maxActive atomic.Int32
|
||||
job := func(ctx context.Context) error {
|
||||
n := active.Add(1)
|
||||
if n > maxActive.Load() {
|
||||
maxActive.Store(n)
|
||||
}
|
||||
defer active.Add(-1)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := r.Every("periodic", 3*time.Millisecond, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Every("periodic", time.Second, job); err == nil {
|
||||
t.Fatal("duplicate schedule accepted")
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for r.Stats().Completed < 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if maxActive.Load() != 1 || r.Stats().Skipped == 0 || r.Stats().Failed < 2 {
|
||||
t.Fatal(maxActive.Load(), r.Stats())
|
||||
}
|
||||
}
|
||||
@@ -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
45
transport/http2/PROXY.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# HTTP/2 代理回源验收
|
||||
|
||||
本项验证的链路是:`Go HTTP/2 客户端 → TLS/h2 → Nginx → TLS/h2 → transport/http2`。客户端与回源两段分别检查协议、TLS 和请求内容;它使用真实 Nginx 进程及生成的测试证书。
|
||||
|
||||
Nginx 的 `proxy_http_version 2` 从 1.29.4 起可用,见 [官方配置说明](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_http_version)。本次固定使用 1.30.5。原有 `scripts/verifyProxy.py` 和 Nginx 1.27 环境继续用于 native HTTP/1.1 回源验收。
|
||||
|
||||
## 运行
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyHTTP2Proxy.py --count 3
|
||||
python3 scripts/verifyHTTP2Proxy.py --race --count 3 --output performance/http2ProxyRaceLinux.json
|
||||
```
|
||||
|
||||
需要指定 Go 模块代理时追加 `--goproxy https://goproxy.cn,direct`。普通模式使用 `nginx:1.30.5-alpine`,race 模式使用 `nginx:1.30.5`,以提供 Go race 二进制需要的 glibc 运行环境;使用 `--nginx-image` 覆盖时需自行保持该兼容性。
|
||||
|
||||
脚本先在 `golang:1.25` 中执行 vet 并编译测试二进制,清理编译容器后,再在 Nginx 镜像中执行测试。两个阶段顺序运行,每个容器固定限制为 2 核、2 GiB 内存、禁用 swap、256 个进程/线程。整个运行默认 600 秒总时限,单次测试进程还有 120 秒时限。
|
||||
|
||||
集成测试容器使用 `--network none`、只读根文件系统和 128 MiB 临时内存目录,没有发布端口。客户端、Nginx 和新旧两个后端都只监听容器内回环地址。超时、SIGTERM、SIGHUP 会清理当前运行拥有的容器;检测到其他 HTTP/2 验收容器运行时,脚本会拒绝开始。
|
||||
|
||||
## 验收内容
|
||||
|
||||
| 场景 | 必须满足的结果 |
|
||||
| --- | --- |
|
||||
| 双段 TLS/h2 | 客户端验证代理证书;代理验证源服务器证书和主机名。业务 handler 必须看到 HTTP/2、ALPN h2 和预期 SNI。 |
|
||||
| 回源连接复用 | 32 次顺序 POST 仅建立 1 条回源 TLS 连接;每次校验独立 requestId、方法、请求体长度与 SHA-256。 |
|
||||
| 证书失败 | 不可信 CA 和错误主机名分别得到 502,均未进入业务 handler;正常路由随后仍可请求成功。 |
|
||||
| 超时 | 代理 250 ms 读取超时得到 504,后端请求 context 被取消、活动请求回到 0,后续请求恢复。 |
|
||||
| 客户端取消 | 客户端取消后,后端 handler 能退出并释放请求槽位,后续请求恢复。 |
|
||||
| GOAWAY 与新旧实例切换 | 旧实例保留一个处理中的 POST;开始 Shutdown 后必须观察到 GOAWAY。随后 20 个独立 POST 进入备用的新实例,旧 POST 释放后仍返回原实例的正确响应。旧实例最终连接/请求归零,业务执行无重复。 |
|
||||
|
||||
新旧实例场景使用 Nginx primary/backup 上游与有限重试;测试没有开启 `non_idempotent` 重试。除了检查每次响应,还检查两个实例的实际 handler 次数及重复 requestId,避免重试掩盖重复执行。
|
||||
|
||||
本次 Nginx 在超时和取消场景中关闭了相关回源连接,随后重新建连恢复;这里验收的是请求取消与资源回收,不要求代理必须通过 RST_STREAM 保留同一条回源连接。
|
||||
|
||||
## 已有结果与边界
|
||||
|
||||
2026-09-23,Linux amd64、Go 1.25.14、Nginx 1.30.5:普通模式和 race 模式各连续 3 轮全部通过,共 18 个子场景,无数据竞争。两轮运行后原有 7 个业务容器仍在运行。
|
||||
|
||||
- [普通模式报告](../../performance/http2ProxyLinux.json) / [原始日志](../../performance/http2ProxyLinux.txt)
|
||||
- [race 报告](../../performance/http2ProxyRaceLinux.json) / [原始日志](../../performance/http2ProxyRaceLinux.txt)
|
||||
- [总时限、SIGTERM、SIGHUP 清理实测](../../performance/http2ProxyCleanupLinux.json)
|
||||
|
||||
报告记录测试提交、源码 SHA-256、镜像 ID、完整执行命令、结果与日志校验值。它是功能联调,不是代理吞吐基准;使用单进程 Nginx 测试配置,验证后端实例切换,不覆盖 Nginx master/worker 配置重载、公网链路或 Cloudflare 实网部署。`transport/http2` 仍是独立实验适配器,native 的 HTTP/1.1 默认路径保持现状。
|
||||
116
transport/http2/admission_test.go
Normal file
116
transport/http2/admission_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func TestTLSConnectionLimitRecoversAfterHandshakeTimeout(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
var handled atomic.Int32
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
handled.Add(1)
|
||||
_, _ = io.WriteString(writer, "ok")
|
||||
}))
|
||||
server.MaxConnections = 1
|
||||
server.TLSHandshakeTimeout = time.Second
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
|
||||
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Occupy the only connection slot without sending a TLS ClientHello.
|
||||
held, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 1 })
|
||||
|
||||
rejected, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rejected.Close()
|
||||
if err := rejected.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buffer [1]byte
|
||||
if _, err := rejected.Read(buffer[:]); err == nil {
|
||||
t.Fatal("connection above the cap was not closed")
|
||||
} else if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
|
||||
t.Fatal("connection above the cap was not rejected promptly")
|
||||
}
|
||||
if stats := server.Stats(); stats.RejectedConnections != 1 || handled.Load() != 0 {
|
||||
t.Fatalf("unexpected admission state: %+v handled=%d", stats, handled.Load())
|
||||
}
|
||||
|
||||
// The handshake deadline, rather than a client disconnect, must release it.
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 0 })
|
||||
if err := held.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := held.Read(buffer[:]); err == nil {
|
||||
t.Fatal("timed-out TLS connection remained open")
|
||||
} else if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
|
||||
t.Fatal("timed-out TLS connection was not closed")
|
||||
}
|
||||
|
||||
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
}}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
response, err := client.Get("https://" + listener.Addr().String() + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || string(body) != "ok" {
|
||||
t.Fatalf("TLS admission did not recover: status=%d protocol=%s body=%q err=%v", response.StatusCode, response.Proto, body, readErr)
|
||||
}
|
||||
transport.CloseIdleConnections()
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 0 && stats.ActiveRequests == 0 })
|
||||
if stats := server.Stats(); stats.AcceptedConnections != 3 || stats.RejectedConnections != 1 || stats.ProtocolErrors != 0 || handled.Load() != 1 {
|
||||
t.Fatalf("unexpected recovered admission state: %+v handled=%d", stats, handled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func waitHTTP2Stats(t *testing.T, server *Server, predicate func(Stats) bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if predicate(server.Stats()) {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("HTTP/2 stats did not reach expected state: %+v", server.Stats())
|
||||
}
|
||||
274
transport/http2/flowControl_test.go
Normal file
274
transport/http2/flowControl_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/hpack"
|
||||
)
|
||||
|
||||
// A zero receive window simulates a client that has stopped consuming DATA.
|
||||
// Headers need no flow-control credit, so a 204 on another stream should still
|
||||
// complete while the large response waits for a WINDOW_UPDATE or cancellation.
|
||||
func TestSlowResponseStreamIsolationAndRecovery(t *testing.T) {
|
||||
payload := strings.Repeat("x", 32<<10)
|
||||
started := make(chan struct{}, 2)
|
||||
written := make(chan error, 2)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/health" {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if request.URL.Path == "/ready" {
|
||||
_, _ = io.WriteString(writer, "ready")
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
writer.(http.Flusher).Flush()
|
||||
started <- struct{}{}
|
||||
_, err := io.WriteString(writer, payload)
|
||||
written <- err
|
||||
}))
|
||||
server.MaxConnections = 2
|
||||
server.MaxConcurrentRequests = 2
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
|
||||
slow := newFramePeer(t, address, xhttp2.Setting{ID: xhttp2.SettingInitialWindowSize, Val: 0})
|
||||
defer slow.Close()
|
||||
slow.framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
|
||||
slow.get(t, 1, "/large")
|
||||
waitSignal(t, started)
|
||||
|
||||
// The same TCP connection can still carry response headers for other streams.
|
||||
slow.get(t, 3, "/health")
|
||||
readNoContent(t, slow, 3)
|
||||
select {
|
||||
case err := <-written:
|
||||
t.Fatalf("large response escaped the zero stream window: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
// A second connection also remains usable while the first handler is blocked.
|
||||
fast := newFramePeer(t, address)
|
||||
defer fast.Close()
|
||||
fast.framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
|
||||
fast.get(t, 1, "/ready")
|
||||
if body := fast.body(t, 1); body != "ready" {
|
||||
t.Fatalf("other connection response: %q", body)
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveConnections != 2 || stats.RejectedRequests != 0 {
|
||||
t.Fatalf("slow response affected other traffic: %+v", stats)
|
||||
}
|
||||
|
||||
// Restoring stream credit must resume the existing response without reconnecting.
|
||||
if err := slow.framer.WriteWindowUpdate(1, uint32(len(payload))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body := slow.body(t, 1); body != payload {
|
||||
t.Fatalf("resumed response mismatch: got %d bytes, want %d", len(body), len(payload))
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err != nil {
|
||||
t.Fatalf("resumed write: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("resumed write did not return")
|
||||
}
|
||||
|
||||
// A new zero-window stream must release its handler when the client cancels.
|
||||
slow.get(t, 5, "/large")
|
||||
waitSignal(t, started)
|
||||
if err := slow.framer.WriteRSTStream(5, xhttp2.ErrCodeCancel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err == nil {
|
||||
t.Fatal("canceled zero-window write unexpectedly succeeded")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("RST_STREAM did not unblock the writer")
|
||||
}
|
||||
|
||||
slow.get(t, 7, "/health")
|
||||
readNoContent(t, slow, 7)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for server.Stats().ActiveRequests != 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveRequests != 0 || stats.RSTStreams != 1 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 {
|
||||
t.Fatalf("flow control did not recover: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
// Leave stream credit available while exhausting the shared connection window.
|
||||
// DATA must pause, but headers and traffic on a different TCP connection must
|
||||
// remain usable until connection-level credit is restored.
|
||||
func TestConnectionWindowIsolationAndRecovery(t *testing.T) {
|
||||
const initialConnectionWindow = 65535
|
||||
payload := strings.Repeat("x", 96<<10)
|
||||
written := make(chan error, 1)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/health":
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
case "/ready":
|
||||
_, _ = io.WriteString(writer, "ready")
|
||||
default:
|
||||
writer.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
writer.(http.Flusher).Flush()
|
||||
_, err := io.WriteString(writer, payload)
|
||||
written <- err
|
||||
}
|
||||
}))
|
||||
server.MaxConnections = 2
|
||||
server.MaxConcurrentRequests = 2
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
|
||||
slow := newFramePeer(t, address, xhttp2.Setting{ID: xhttp2.SettingInitialWindowSize, Val: 128 << 10})
|
||||
defer slow.Close()
|
||||
slow.framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
|
||||
slow.get(t, 1, "/large")
|
||||
var received strings.Builder
|
||||
for received.Len() < initialConnectionWindow {
|
||||
switch frame := slow.read(t).(type) {
|
||||
case *xhttp2.DataFrame:
|
||||
if frame.StreamID != 1 || frame.StreamEnded() {
|
||||
t.Fatalf("unexpected DATA before connection credit was restored: %+v", frame)
|
||||
}
|
||||
received.Write(frame.Data())
|
||||
case *xhttp2.RSTStreamFrame:
|
||||
t.Fatalf("unexpected server reset: %+v", frame)
|
||||
}
|
||||
}
|
||||
if received.Len() != initialConnectionWindow {
|
||||
t.Fatalf("server exceeded connection window: got %d bytes", received.Len())
|
||||
}
|
||||
|
||||
slow.get(t, 3, "/health")
|
||||
readNoContent(t, slow, 3)
|
||||
select {
|
||||
case err := <-written:
|
||||
t.Fatalf("large response escaped exhausted connection window: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
fast := newFramePeer(t, address)
|
||||
defer fast.Close()
|
||||
fast.get(t, 1, "/ready")
|
||||
if body := fast.body(t, 1); body != "ready" {
|
||||
t.Fatalf("other connection response: %q", body)
|
||||
}
|
||||
|
||||
remaining := len(payload) - received.Len()
|
||||
if err := slow.framer.WriteWindowUpdate(0, uint32(remaining)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
received.WriteString(slow.body(t, 1))
|
||||
if received.String() != payload {
|
||||
t.Fatalf("resumed connection response mismatch: got %d bytes, want %d", received.Len(), len(payload))
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err != nil {
|
||||
t.Fatalf("resumed connection write: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("connection WINDOW_UPDATE did not unblock writer")
|
||||
}
|
||||
|
||||
if err := slow.framer.WriteWindowUpdate(0, uint32(len("ready"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slow.get(t, 5, "/ready")
|
||||
if body := slow.body(t, 5); body != "ready" {
|
||||
t.Fatalf("reused connection response: %q", body)
|
||||
}
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveRequests == 0 })
|
||||
stats := server.Stats()
|
||||
if stats.AcceptedConnections != 2 || stats.ActiveConnections != 2 || stats.RejectedConnections != 0 || stats.RejectedRequests != 0 || stats.ProtocolErrors != 0 || stats.RSTStreams != 0 {
|
||||
t.Fatalf("connection flow control did not recover: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownReleasesFlowControlledWriter(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
written := make(chan error, 1)
|
||||
requestContext := make(chan context.Context, 1)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestContext <- request.Context()
|
||||
writer.(http.Flusher).Flush()
|
||||
close(started)
|
||||
_, err := io.WriteString(writer, strings.Repeat("x", 32<<10))
|
||||
written <- err
|
||||
}))
|
||||
address, done := launchServer(t, server, context.Background())
|
||||
peer := newFramePeer(t, address, xhttp2.Setting{ID: xhttp2.SettingInitialWindowSize, Val: 0})
|
||||
defer peer.Close()
|
||||
peer.get(t, 1, "/blocked")
|
||||
waitSignal(t, started)
|
||||
requestCtx := <-requestContext
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
stopped := make(chan error, 1)
|
||||
go func() { stopped <- server.Shutdown(ctx) }()
|
||||
for {
|
||||
if frame, ok := peer.read(t).(*xhttp2.GoAwayFrame); ok {
|
||||
if frame.ErrCode != xhttp2.ErrCodeNo || frame.LastStreamID != 1 {
|
||||
t.Fatalf("unexpected GOAWAY for blocked writer: %+v", frame)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := serveResult(t, stopped); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("shutdown with exhausted receive window: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err == nil {
|
||||
t.Fatal("blocked write unexpectedly succeeded without window credit")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("shutdown deadline did not release flow-controlled writer")
|
||||
}
|
||||
if requestCtx.Err() == nil {
|
||||
t.Fatal("forced shutdown did not cancel request context")
|
||||
}
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveRequests == 0 && stats.ActiveConnections == 0 })
|
||||
if stats := server.Stats(); stats.GoAwayFrames != 1 || stats.ProtocolErrors != 0 {
|
||||
t.Fatalf("blocked connection did not close cleanly: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func readNoContent(t *testing.T, peer *framePeer, streamID uint32) {
|
||||
t.Helper()
|
||||
for {
|
||||
switch frame := peer.read(t).(type) {
|
||||
case *xhttp2.MetaHeadersFrame:
|
||||
if frame.StreamID != streamID {
|
||||
continue
|
||||
}
|
||||
if frame.PseudoValue("status") != "204" || !frame.StreamEnded() {
|
||||
t.Fatalf("stream %d: expected complete 204, got %+v", streamID, frame)
|
||||
}
|
||||
return
|
||||
case *xhttp2.DataFrame:
|
||||
t.Fatalf("unexpected DATA while receive window is exhausted on stream %d", frame.StreamID)
|
||||
case *xhttp2.RSTStreamFrame:
|
||||
t.Fatalf("unexpected server reset: %+v", frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
106
transport/http2/frameObserver.go
Normal file
106
transport/http2/frameObserver.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
frameTypeRSTStream byte = 0x3
|
||||
frameTypeGoAway byte = 0x7
|
||||
)
|
||||
|
||||
type frameDirection uint8
|
||||
|
||||
const (
|
||||
frameRead frameDirection = iota
|
||||
frameWrite
|
||||
)
|
||||
|
||||
// observedConn keeps the HTTP/2 implementation opaque while exposing the
|
||||
// small amount of frame-level telemetry that x/net/http2 does not provide.
|
||||
// It parses only the nine-byte frame header and skips payload bytes without
|
||||
// buffering them, so large DATA frames cannot increase telemetry memory.
|
||||
type observedConn struct {
|
||||
net.Conn
|
||||
onFrame func(frameDirection, byte)
|
||||
readParser frameParser
|
||||
writeParser frameParser
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
func (c *observedConn) Read(buffer []byte) (int, error) {
|
||||
n, err := c.Conn.Read(buffer)
|
||||
if n > 0 {
|
||||
c.readParser.observe(buffer[:n], frameRead, c.onFrame)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *observedConn) Write(buffer []byte) (int, error) {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
n, err := c.Conn.Write(buffer)
|
||||
if n > 0 {
|
||||
c.writeParser.observe(buffer[:n], frameWrite, c.onFrame)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// observedTLSConn preserves the connection state hook used by x/net/http2 so
|
||||
// handlers still receive Request.TLS after frame observation is added.
|
||||
type observedTLSConn struct {
|
||||
*observedConn
|
||||
state tls.ConnectionState
|
||||
}
|
||||
|
||||
func (c *observedTLSConn) ConnectionState() tls.ConnectionState { return c.state }
|
||||
|
||||
type frameParser struct {
|
||||
prefaceRemain int
|
||||
header [9]byte
|
||||
headerBytes int
|
||||
payloadRemain int
|
||||
frameType byte
|
||||
}
|
||||
|
||||
func (p *frameParser) observe(buffer []byte, direction frameDirection, onFrame func(frameDirection, byte)) {
|
||||
for len(buffer) > 0 {
|
||||
if p.prefaceRemain > 0 {
|
||||
skip := p.prefaceRemain
|
||||
if skip > len(buffer) {
|
||||
skip = len(buffer)
|
||||
}
|
||||
p.prefaceRemain -= skip
|
||||
buffer = buffer[skip:]
|
||||
continue
|
||||
}
|
||||
if p.payloadRemain > 0 {
|
||||
skip := p.payloadRemain
|
||||
if skip > len(buffer) {
|
||||
skip = len(buffer)
|
||||
}
|
||||
p.payloadRemain -= skip
|
||||
buffer = buffer[skip:]
|
||||
if p.payloadRemain > 0 {
|
||||
continue
|
||||
}
|
||||
onFrame(direction, p.frameType)
|
||||
continue
|
||||
}
|
||||
|
||||
copied := copy(p.header[p.headerBytes:], buffer)
|
||||
p.headerBytes += copied
|
||||
buffer = buffer[copied:]
|
||||
if p.headerBytes < len(p.header) {
|
||||
continue
|
||||
}
|
||||
p.payloadRemain = int(p.header[0])<<16 | int(p.header[1])<<8 | int(p.header[2])
|
||||
p.frameType = p.header[3]
|
||||
p.headerBytes = 0
|
||||
if p.payloadRemain == 0 {
|
||||
onFrame(direction, p.frameType)
|
||||
}
|
||||
}
|
||||
}
|
||||
249
transport/http2/lifecycle_test.go
Normal file
249
transport/http2/lifecycle_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/hpack"
|
||||
)
|
||||
|
||||
// A raw peer makes GOAWAY and RST_STREAM observable without client retries.
|
||||
type framePeer struct {
|
||||
net.Conn
|
||||
framer *xhttp2.Framer
|
||||
}
|
||||
|
||||
func newFramePeer(t *testing.T, address string, settings ...xhttp2.Setting) *framePeer {
|
||||
t.Helper()
|
||||
c, err := net.DialTimeout("tcp", address, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
p := &framePeer{c, xhttp2.NewFramer(c, c)}
|
||||
if _, err = io.WriteString(c, xhttp2.ClientPreface); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = p.framer.WriteSettings(settings...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (p *framePeer) get(t *testing.T, id uint32, path string) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
e := hpack.NewEncoder(&b)
|
||||
for _, field := range []hpack.HeaderField{{Name: ":method", Value: "GET"}, {Name: ":scheme", Value: "http"}, {Name: ":authority", Value: "localhost"}, {Name: ":path", Value: path}} {
|
||||
if err := e.WriteField(field); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := p.framer.WriteHeaders(xhttp2.HeadersFrameParam{StreamID: id, EndStream: true, EndHeaders: true, BlockFragment: b.Bytes()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func (p *framePeer) read(t *testing.T) xhttp2.Frame {
|
||||
t.Helper()
|
||||
f, err := p.framer.ReadFrame()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, ok := f.(*xhttp2.SettingsFrame); ok && !s.IsAck() {
|
||||
if err := p.framer.WriteSettingsAck(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
func (p *framePeer) body(t *testing.T, id uint32) string {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
for {
|
||||
switch f := p.read(t).(type) {
|
||||
case *xhttp2.DataFrame:
|
||||
if f.StreamID != id {
|
||||
t.Fatalf("unexpected stream %d", f.StreamID)
|
||||
}
|
||||
b.Write(f.Data())
|
||||
if f.StreamEnded() {
|
||||
return b.String()
|
||||
}
|
||||
case *xhttp2.HeadersFrame:
|
||||
if f.StreamID == id && f.StreamEnded() {
|
||||
return b.String()
|
||||
}
|
||||
case *xhttp2.RSTStreamFrame:
|
||||
t.Fatalf("unexpected RST_STREAM: %v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
func waitSignal(t *testing.T, signal <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-signal:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for event")
|
||||
}
|
||||
}
|
||||
func launchServer(t *testing.T, s *Server, ctx context.Context) (string, <-chan error) {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.Serve(ctx, l) }()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(ctx); err != nil {
|
||||
t.Errorf("cleanup shutdown: %v", err)
|
||||
}
|
||||
l.Close()
|
||||
})
|
||||
return l.Addr().String(), done
|
||||
}
|
||||
func serveResult(t *testing.T, done <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not finish")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func TestShutdownSendsGoAwayAndDrains(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
requestCtx := make(chan context.Context, 1)
|
||||
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/hold" {
|
||||
t.Error("new stream reached handler during drain")
|
||||
return
|
||||
}
|
||||
requestCtx <- r.Context()
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
io.WriteString(w, "completed")
|
||||
}))
|
||||
address, done := launchServer(t, s, context.Background())
|
||||
p := newFramePeer(t, address)
|
||||
p.get(t, 1, "/hold")
|
||||
waitSignal(t, started)
|
||||
rctx := <-requestCtx
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
stopped := make(chan error, 1)
|
||||
go func() { stopped <- s.Shutdown(ctx) }()
|
||||
for {
|
||||
if f, ok := p.read(t).(*xhttp2.GoAwayFrame); ok {
|
||||
if f.ErrCode != xhttp2.ErrCodeNo || f.LastStreamID != 1 {
|
||||
t.Fatalf("GOAWAY=%+v", f)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := rctx.Err(); err != nil {
|
||||
t.Fatalf("graceful shutdown canceled active request: %v", err)
|
||||
}
|
||||
if stats := s.Stats(); stats.GoAwayFrames != 1 {
|
||||
t.Fatalf("expected one observed GOAWAY frame, got %+v", stats)
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("Serve returned before drain: %v", err)
|
||||
default:
|
||||
}
|
||||
p.get(t, 3, "/late")
|
||||
release <- struct{}{}
|
||||
if got := p.body(t, 1); got != "completed" {
|
||||
t.Fatalf("body=%q", got)
|
||||
}
|
||||
p.Close()
|
||||
if err := serveResult(t, stopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats := s.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.GoAwaySent != 1 {
|
||||
t.Fatalf("not drained: %+v", stats)
|
||||
}
|
||||
}
|
||||
func TestRSTStreamCancelsOnlyOneRequest(t *testing.T) {
|
||||
started := make(chan struct{}, 2)
|
||||
canceled := make(chan string, 2)
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started <- struct{}{}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
canceled <- r.URL.Path
|
||||
return
|
||||
case <-release:
|
||||
io.WriteString(w, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
s.MaxConcurrentRequests = 2
|
||||
address, _ := launchServer(t, s, context.Background())
|
||||
p := newFramePeer(t, address)
|
||||
p.get(t, 1, "/cancel")
|
||||
p.get(t, 3, "/survive")
|
||||
waitSignal(t, started)
|
||||
waitSignal(t, started)
|
||||
if err := p.framer.WriteRSTStream(1, xhttp2.ErrCodeCancel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case path := <-canceled:
|
||||
if path != "/cancel" {
|
||||
t.Fatalf("canceled %s", path)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("RST did not cancel request")
|
||||
}
|
||||
if stats := s.Stats(); stats.RSTStreams != 1 {
|
||||
t.Fatalf("expected one observed RST_STREAM, got %+v", stats)
|
||||
}
|
||||
release <- struct{}{}
|
||||
if got := p.body(t, 3); got != "/survive" {
|
||||
t.Fatalf("sibling body=%q", got)
|
||||
}
|
||||
p.get(t, 5, "/reused")
|
||||
waitSignal(t, started)
|
||||
release <- struct{}{}
|
||||
if got := p.body(t, 5); got != "/reused" {
|
||||
t.Fatalf("connection reuse body=%q", got)
|
||||
}
|
||||
}
|
||||
func TestCanceledServeContextWaitsForShutdownDeadline(t *testing.T) {
|
||||
started, canceled := make(chan struct{}), make(chan struct{})
|
||||
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-r.Context().Done(); close(canceled) }))
|
||||
s.ShutdownTimeout = 100 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
address, done := launchServer(t, s, ctx)
|
||||
p := newFramePeer(t, address)
|
||||
p.get(t, 1, "/hold")
|
||||
waitSignal(t, started)
|
||||
cancel()
|
||||
if err := serveResult(t, done); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Serve should report drain timeout, got %v", err)
|
||||
}
|
||||
waitSignal(t, canceled)
|
||||
}
|
||||
304
transport/http2/mixedSoak_test.go
Normal file
304
transport/http2/mixedSoak_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
type mixedConnectionCounts struct {
|
||||
health atomic.Uint64
|
||||
slowUploads atomic.Uint64
|
||||
slowDownloads atomic.Uint64
|
||||
}
|
||||
|
||||
// TestTLSMixedSoak shares each of eight connections between fourteen fast
|
||||
// workers, one paced uploader and one paced downloader. The 8 MiB download is
|
||||
// larger than x/net v0.25.0's 4 MiB initial stream window, requiring window
|
||||
// updates to finish. This does not exhaust the connection-level receive window.
|
||||
func TestTLSMixedSoak(t *testing.T) {
|
||||
const connectionCount, workersPerConnection = 8, 16
|
||||
const uploadBytes, downloadBytes = 256 << 10, 8 << 20
|
||||
started := time.Now()
|
||||
duration := soakDuration(t)
|
||||
logSoakResources(t, started, "before", 0, 0, Stats{})
|
||||
t.Logf("scenario=mixed connections=%d workersPerConnection=%d uploadBytes=%d uploadChunkBytes=4096 uploadDelay=2ms downloadBytes=%d downloadChunkBytes=65536 downloadDelay=20ms",
|
||||
connectionCount, workersPerConnection, uploadBytes, downloadBytes)
|
||||
downloadPayload := bytes.Repeat([]byte("x"), downloadBytes)
|
||||
expectedDownload := sha256.Sum256(downloadPayload)
|
||||
expectedUpload := sha256.Sum256(downloadPayload[:uploadBytes])
|
||||
certificate, roots := testCertificate(t)
|
||||
var serverFailures atomic.Uint64
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("X-Soak-Request-Id", request.Header.Get("X-Soak-Request-Id"))
|
||||
switch request.URL.Path {
|
||||
case "/slowUpload":
|
||||
digest := sha256.New()
|
||||
count, err := io.Copy(digest, request.Body)
|
||||
if err != nil || count != uploadBytes || !bytes.Equal(digest.Sum(nil), expectedUpload[:]) {
|
||||
serverFailures.Add(1)
|
||||
http.Error(writer, "incomplete upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(writer, "uploaded")
|
||||
case "/slowDownload":
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(downloadBytes))
|
||||
if n, err := writer.Write(downloadPayload); err != nil || n != downloadBytes {
|
||||
serverFailures.Add(1)
|
||||
}
|
||||
default:
|
||||
_, _ = io.WriteString(writer, "healthy")
|
||||
}
|
||||
}))
|
||||
server.MaxConnections = connectionCount
|
||||
server.MaxConcurrentRequests = connectionCount * workersPerConnection
|
||||
server.MaxConcurrentStreams = workersPerConnection
|
||||
server.MaxBodyBytes = 1 << 20
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
|
||||
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}()
|
||||
var transports []*xhttp2.Transport
|
||||
var clients []*http.Client
|
||||
for index := 0; index < connectionCount; index++ {
|
||||
transport := &xhttp2.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12, NextProtos: []string{"h2"}},
|
||||
StrictMaxConcurrentStreams: true,
|
||||
}
|
||||
transports = append(transports, transport)
|
||||
clients = append(clients, &http.Client{Transport: transport, Timeout: 20 * time.Second})
|
||||
}
|
||||
var stopOnce sync.Once
|
||||
stop := func() {
|
||||
stopOnce.Do(func() {
|
||||
for _, transport := range transports {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
t.Errorf("mixed soak shutdown: %v", err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Errorf("mixed soak serve: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
defer stop()
|
||||
|
||||
var counts [connectionCount]mixedConnectionCounts
|
||||
var requests, failures atomic.Uint64
|
||||
var failureOnce sync.Once
|
||||
var firstFailure string
|
||||
recordFailure := func(err error) {
|
||||
failures.Add(1)
|
||||
failureOnce.Do(func() { firstFailure = err.Error() })
|
||||
}
|
||||
logCounts := func(phase string) {
|
||||
var health, uploads, downloads uint64
|
||||
for i := range counts {
|
||||
health += counts[i].health.Load()
|
||||
uploads += counts[i].slowUploads.Load()
|
||||
downloads += counts[i].slowDownloads.Load()
|
||||
}
|
||||
t.Logf("mixedTotals phase=%s health=%d slowUploads=%d slowDownloads=%d uploadedBytes=%d downloadedBytes=%d serverFailures=%d",
|
||||
phase, health, uploads, downloads, uploads*uploadBytes, downloads*downloadBytes, serverFailures.Load())
|
||||
}
|
||||
monitorStop, monitorDone := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(monitorDone)
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
logSoakResources(t, started, "running", requests.Load(), failures.Load(), server.Stats())
|
||||
logCounts("running")
|
||||
case <-monitorStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
var monitorOnce sync.Once
|
||||
stopMonitor := func() { monitorOnce.Do(func() { close(monitorStop); <-monitorDone }) }
|
||||
defer stopMonitor()
|
||||
|
||||
deadline := time.Now().Add(duration)
|
||||
var workers sync.WaitGroup
|
||||
for worker := 0; worker < connectionCount*workersPerConnection; worker++ {
|
||||
workers.Add(1)
|
||||
go func(worker int) {
|
||||
defer workers.Done()
|
||||
connection := worker / workersPerConnection
|
||||
kind := worker % workersPerConnection
|
||||
for sequence := uint64(0); time.Now().Before(deadline) && failures.Load() == 0; sequence++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
path, method := "/health", http.MethodGet
|
||||
var body io.Reader
|
||||
if kind == 0 {
|
||||
path, method = "/slowUpload", http.MethodPost
|
||||
body = &pacedUpload{ctx: ctx, remaining: uploadBytes}
|
||||
} else if kind == 1 {
|
||||
path = "/slowDownload"
|
||||
}
|
||||
requestID := strconv.Itoa(worker) + ":" + strconv.FormatUint(sequence, 10)
|
||||
request, err := http.NewRequestWithContext(ctx, method, "https://"+listener.Addr().String()+path, body)
|
||||
if err == nil {
|
||||
request.Header.Set("X-Soak-Request-Id", requestID)
|
||||
err = checkMixedResponse(ctx, clients[connection], request, kind, downloadBytes, expectedDownload)
|
||||
}
|
||||
cancel()
|
||||
if err != nil {
|
||||
recordFailure(fmt.Errorf("connection=%d path=%s: %w", connection, path, err))
|
||||
continue
|
||||
}
|
||||
requests.Add(1)
|
||||
switch kind {
|
||||
case 0:
|
||||
counts[connection].slowUploads.Add(1)
|
||||
case 1:
|
||||
counts[connection].slowDownloads.Add(1)
|
||||
default:
|
||||
counts[connection].health.Add(1)
|
||||
}
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
workers.Wait()
|
||||
stopMonitor()
|
||||
stop()
|
||||
logSoakResources(t, started, "after", requests.Load(), failures.Load(), server.Stats())
|
||||
logCounts("after")
|
||||
var completed uint64
|
||||
for index := range counts {
|
||||
c := &counts[index]
|
||||
completed += c.health.Load() + c.slowUploads.Load() + c.slowDownloads.Load()
|
||||
t.Logf("connection=%d health=%d slowUploads=%d slowDownloads=%d", index, c.health.Load(), c.slowUploads.Load(), c.slowDownloads.Load())
|
||||
if c.health.Load() == 0 || c.slowUploads.Load() == 0 || c.slowDownloads.Load() == 0 {
|
||||
t.Errorf("connection %d did not complete all workload types", index)
|
||||
}
|
||||
}
|
||||
if completed != requests.Load() {
|
||||
t.Errorf("mixed workload accounting: completed=%d requests=%d", completed, requests.Load())
|
||||
}
|
||||
// Release workload fixtures and idle client pools before measuring retained
|
||||
// objects. Two GCs also allow sync.Pool's previous generation to be collected.
|
||||
downloadPayload, clients, transports = nil, nil, nil
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
logSoakResources(t, started, "afterReclaim", requests.Load(), failures.Load(), server.Stats())
|
||||
if failures.Load() != 0 || serverFailures.Load() != 0 {
|
||||
t.Fatalf("mixed soak failures=%d serverFailures=%d firstFailure=%q", failures.Load(), serverFailures.Load(), firstFailure)
|
||||
}
|
||||
if stats := server.Stats(); stats.AcceptedConnections != connectionCount || stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 || stats.RejectedConnections != 0 {
|
||||
t.Fatalf("mixed soak connections, errors or recovery: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func checkMixedResponse(ctx context.Context, client *http.Client, request *http.Request, kind, downloadBytes int, expectedHash [sha256.Size]byte) error {
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || response.TLS == nil || response.TLS.NegotiatedProtocol != "h2" || response.Header.Get("X-Soak-Request-Id") != request.Header.Get("X-Soak-Request-Id") {
|
||||
return fmt.Errorf("unexpected status, protocol or request ID: %s", response.Status)
|
||||
}
|
||||
if kind != 1 {
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||
expected := "healthy"
|
||||
if kind == 0 {
|
||||
expected = "uploaded"
|
||||
}
|
||||
if err != nil || string(body) != expected {
|
||||
return fmt.Errorf("invalid response %q: %v", body, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
digest := sha256.New()
|
||||
buffer := make([]byte, 64<<10)
|
||||
total := 0
|
||||
for {
|
||||
n, err := response.Body.Read(buffer)
|
||||
total += n
|
||||
if total > downloadBytes {
|
||||
return fmt.Errorf("download exceeds %d bytes", downloadBytes)
|
||||
}
|
||||
if n > 0 {
|
||||
_, _ = digest.Write(buffer[:n])
|
||||
if pauseErr := pauseMixedClient(ctx, 20*time.Millisecond); pauseErr != nil {
|
||||
return pauseErr
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if total != downloadBytes || !bytes.Equal(digest.Sum(nil), expectedHash[:]) {
|
||||
return fmt.Errorf("download length or digest mismatch: %d bytes", total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type pacedUpload struct {
|
||||
ctx context.Context
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (r *pacedUpload) Read(buffer []byte) (int, error) {
|
||||
if r.remaining == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(buffer) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := pauseMixedClient(r.ctx, 2*time.Millisecond); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := len(buffer)
|
||||
if n > 4096 {
|
||||
n = 4096
|
||||
}
|
||||
if n > r.remaining {
|
||||
n = r.remaining
|
||||
}
|
||||
for index := 0; index < n; index++ {
|
||||
buffer[index] = 'x'
|
||||
}
|
||||
r.remaining -= n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func pauseMixedClient(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
459
transport/http2/proxy_test.go
Normal file
459
transport/http2/proxy_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// This opt-in suite runs against a real Nginx executable. The Linux wrapper
|
||||
// builds the test binary separately, then runs it in an isolated Nginx image.
|
||||
func TestNginxHTTP2Proxy(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_HTTP2_PROXY_TEST") != "1" {
|
||||
t.Skip("use scripts/verifyHTTP2Proxy.py for real Nginx HTTP/2 upstream validation")
|
||||
}
|
||||
if output, err := exec.Command("nginx", "-v").CombinedOutput(); err != nil {
|
||||
t.Fatalf("nginx unavailable: %v: %s", err, output)
|
||||
} else {
|
||||
t.Logf("proxyVersion=%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
t.Run("TLSAndConnectionReuse", testProxyTLSAndReuse)
|
||||
t.Run("TimeoutAndCancellation", testProxyTimeoutAndCancellation)
|
||||
t.Run("GoAwayAndRollingDrain", testProxyGoAwayAndDrain)
|
||||
}
|
||||
|
||||
type proxyReply struct {
|
||||
Instance string `json:"instance"`
|
||||
RequestID string `json:"requestId"`
|
||||
Protocol string `json:"protocol"`
|
||||
ALPN string `json:"alpn"`
|
||||
Method string `json:"method"`
|
||||
BodyBytes int `json:"bodyBytes"`
|
||||
BodySHA256 string `json:"bodySha256"`
|
||||
}
|
||||
|
||||
type proxyOrigin struct {
|
||||
server *Server
|
||||
address string
|
||||
done chan struct{}
|
||||
serveErr error
|
||||
requests atomic.Uint64
|
||||
duplicates atomic.Uint64
|
||||
seen sync.Map
|
||||
}
|
||||
|
||||
func newProxyOrigin(t *testing.T, certificate tls.Certificate, instance string, beforeReply func(*http.Request)) *proxyOrigin {
|
||||
t.Helper()
|
||||
o := &proxyOrigin{done: make(chan struct{})}
|
||||
o.server = New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.ProtoMajor != 2 || request.TLS == nil || request.TLS.NegotiatedProtocol != "h2" || request.TLS.ServerName != "localhost" {
|
||||
t.Error("proxy upstream did not negotiate verified-name TLS/h2")
|
||||
http.Error(writer, "wrong upstream protocol", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
o.requests.Add(1)
|
||||
id := request.Header.Get("X-Request-ID")
|
||||
if _, loaded := o.seen.LoadOrStore(id, struct{}{}); loaded {
|
||||
o.duplicates.Add(1)
|
||||
}
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
http.Error(writer, "body read failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if beforeReply != nil {
|
||||
beforeReply(request)
|
||||
}
|
||||
if request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("X-Request-ID", id)
|
||||
_ = json.NewEncoder(writer).Encode(proxyReply{
|
||||
Instance: instance, RequestID: id, Protocol: request.Proto, ALPN: request.TLS.NegotiatedProtocol,
|
||||
Method: request.Method, BodyBytes: len(body), BodySHA256: fmt.Sprintf("%x", sha256.Sum256(body)),
|
||||
})
|
||||
}))
|
||||
o.server.MaxConnections = 16
|
||||
o.server.MaxConcurrentRequests = 32
|
||||
o.server.MaxConcurrentStreams = 32
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.address = listener.Addr().String()
|
||||
go func() {
|
||||
o.serveErr = o.server.ServeTLS(context.Background(), listener, &tls.Config{
|
||||
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
close(o.done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := o.shutdown(ctx); err != nil {
|
||||
t.Errorf("origin cleanup: %v", err)
|
||||
}
|
||||
stats := o.server.Stats()
|
||||
if stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || o.duplicates.Load() != 0 {
|
||||
t.Errorf("origin lifecycle/duplicate failure: stats=%+v duplicates=%d", stats, o.duplicates.Load())
|
||||
}
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
func (o *proxyOrigin) shutdown(ctx context.Context) error {
|
||||
if err := o.server.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-o.done:
|
||||
return o.serveErr
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func testProxyTLSAndReuse(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
origin := newProxyOrigin(t, certificate, "primary", nil)
|
||||
endpoint := startTestNginx(t, certificate, origin.address, "")
|
||||
client := proxyClient(t, roots)
|
||||
for index := 0; index < 32; index++ {
|
||||
payload := []byte(fmt.Sprintf("payload-%d", index))
|
||||
result, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodPost, fmt.Sprintf("reuse-%d", index), payload, 200)
|
||||
if err != nil || result.Instance != "primary" {
|
||||
t.Fatalf("reuse request %d: %+v %v", index, result, err)
|
||||
}
|
||||
}
|
||||
stats := origin.server.Stats()
|
||||
if stats.AcceptedConnections != 1 || stats.ActiveConnections != 1 || origin.requests.Load() != 32 {
|
||||
t.Fatalf("upstream TLS connection was not reused: %+v requests=%d", stats, origin.requests.Load())
|
||||
}
|
||||
for _, path := range []string{"/badTrust", "/badName"} {
|
||||
if _, err := proxyCall(context.Background(), client, endpoint+path, http.MethodGet, path, nil, 502); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if origin.requests.Load() != 32 {
|
||||
t.Fatal("an upstream with invalid certificate trust or hostname reached the handler")
|
||||
}
|
||||
if _, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "afterTLSFailures", nil, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("proxyResult=tlsAndReuse reusedRequests=32 upstreamConnections=1 invalidTrustStatus=502 invalidNameStatus=502 recovered=true")
|
||||
}
|
||||
|
||||
func testProxyTimeoutAndCancellation(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
started, canceled := make(chan string, 2), make(chan string, 2)
|
||||
origin := newProxyOrigin(t, certificate, "primary", func(request *http.Request) {
|
||||
if request.URL.Path == "/timeout" || request.URL.Path == "/cancel" {
|
||||
started <- request.URL.Path
|
||||
<-request.Context().Done()
|
||||
canceled <- request.URL.Path
|
||||
}
|
||||
})
|
||||
endpoint := startTestNginx(t, certificate, origin.address, "")
|
||||
client := proxyClient(t, roots)
|
||||
begin := time.Now()
|
||||
if _, err := proxyCall(context.Background(), client, endpoint+"/timeout", http.MethodGet, "timeout", nil, 504); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Since(begin) > 2*time.Second {
|
||||
t.Fatal("upstream timeout was not bounded")
|
||||
}
|
||||
awaitProxyPath(t, started, "/timeout")
|
||||
awaitProxyPath(t, canceled, "/timeout")
|
||||
waitHTTP2Stats(t, origin.server, func(stats Stats) bool { return stats.ActiveRequests == 0 })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := proxyCall(ctx, client, endpoint+"/cancel", http.MethodGet, "cancel", nil, 200)
|
||||
result <- err
|
||||
}()
|
||||
awaitProxyPath(t, started, "/cancel")
|
||||
cancel()
|
||||
if err := serveResult(t, result); err == nil {
|
||||
t.Fatal("canceled proxy request unexpectedly succeeded")
|
||||
}
|
||||
awaitProxyPath(t, canceled, "/cancel")
|
||||
waitHTTP2Stats(t, origin.server, func(stats Stats) bool { return stats.ActiveRequests == 0 })
|
||||
if _, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "afterCancellation", nil, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("proxyResult=timeoutAndCancellation timeoutStatus=504 canceledHandlers=2 recovered=true stats=%+v", origin.server.Stats())
|
||||
}
|
||||
|
||||
func testProxyGoAwayAndDrain(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
started := make(chan context.Context, 1)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
releaseOld := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
defer releaseOld()
|
||||
old := newProxyOrigin(t, certificate, "old", func(request *http.Request) {
|
||||
if request.URL.Path == "/hold" {
|
||||
started <- request.Context()
|
||||
select {
|
||||
case <-release:
|
||||
case <-request.Context().Done():
|
||||
}
|
||||
}
|
||||
})
|
||||
next := newProxyOrigin(t, certificate, "new", nil)
|
||||
endpoint := startTestNginx(t, certificate, old.address, next.address)
|
||||
client := proxyClient(t, roots)
|
||||
if reply, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodGet, "beforeRollout", nil, 200); err != nil || reply.Instance != "old" {
|
||||
t.Fatalf("initial request: %+v %v", reply, err)
|
||||
}
|
||||
held := make(chan error, 1)
|
||||
go func() {
|
||||
reply, err := proxyCall(context.Background(), client, endpoint+"/hold", http.MethodPost, "heldOld", []byte("finish exactly once"), 200)
|
||||
if err == nil && reply.Instance != "old" {
|
||||
err = fmt.Errorf("held request switched instance: %+v", reply)
|
||||
}
|
||||
held <- err
|
||||
}()
|
||||
var heldContext context.Context
|
||||
select {
|
||||
case heldContext = <-started:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("held request never reached old instance")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
stopped := make(chan error, 1)
|
||||
go func() { stopped <- old.shutdown(ctx) }()
|
||||
waitHTTP2Stats(t, old.server, func(stats Stats) bool { return stats.GoAwayFrames > 0 })
|
||||
if heldContext.Err() != nil {
|
||||
t.Fatal("GOAWAY canceled the in-flight request")
|
||||
}
|
||||
// The primary listener is closed, so Nginx must route new requests to the
|
||||
// backup while the old HTTP/2 stream is still draining. POSTs also prove
|
||||
// request bodies and IDs survive this transition without duplicate handlers.
|
||||
for index := 0; index < 20; index++ {
|
||||
reply, err := proxyCall(context.Background(), client, endpoint+"/echo", http.MethodPost, fmt.Sprintf("rollout-%d", index), []byte(fmt.Sprint(index)), 200)
|
||||
if err != nil || reply.Instance != "new" {
|
||||
t.Fatalf("request during drain %d: %+v %v", index, reply, err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-held:
|
||||
t.Fatalf("held request completed before release: %v", err)
|
||||
default:
|
||||
}
|
||||
if old.requests.Load() != 2 || next.requests.Load() != 20 {
|
||||
t.Fatalf("rollout request accounting: old=%d new=%d", old.requests.Load(), next.requests.Load())
|
||||
}
|
||||
releaseOld()
|
||||
if err := serveResult(t, held); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := serveResult(t, stopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := old.server.Stats()
|
||||
if stats.ActiveRequests != 0 || stats.ActiveConnections != 0 || stats.GoAwayFrames == 0 {
|
||||
t.Fatalf("old instance failed to drain: %+v", stats)
|
||||
}
|
||||
t.Logf("proxyResult=goAwayAndRollingDrain oldRequests=2 newRequests=20 heldPostCompleted=true duplicateHandlers=0 oldStats=%+v", stats)
|
||||
}
|
||||
|
||||
func awaitProxyPath(t *testing.T, events <-chan string, expected string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case path := <-events:
|
||||
if path != expected {
|
||||
t.Fatalf("handler event: got %s, want %s", path, expected)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for handler event: %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func proxyClient(t *testing.T, roots *x509.CertPool) *http.Client {
|
||||
t.Helper()
|
||||
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12, NextProtos: []string{"h2"},
|
||||
}, StrictMaxConcurrentStreams: true}
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
return &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
func proxyCall(ctx context.Context, client *http.Client, endpoint, method, id string, body []byte, expectedStatus int) (proxyReply, error) {
|
||||
var reply proxyReply
|
||||
request, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
request.Header.Set("X-Request-ID", id)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, 8192))
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if response.StatusCode != expectedStatus || response.ProtoMajor != 2 || response.TLS == nil || response.TLS.NegotiatedProtocol != "h2" || len(response.TLS.VerifiedChains) == 0 {
|
||||
return reply, fmt.Errorf("unverified edge TLS/h2 or status: %s body=%q", response.Status, data)
|
||||
}
|
||||
if expectedStatus != 200 {
|
||||
return reply, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &reply); err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if reply.RequestID != id || response.Header.Get("X-Request-ID") != id || reply.Protocol != "HTTP/2.0" || reply.ALPN != "h2" || reply.Method != method || reply.BodyBytes != len(body) || reply.BodySHA256 != fmt.Sprintf("%x", sha256.Sum256(body)) {
|
||||
return reply, fmt.Errorf("upstream protocol, request ID or body mismatch: %+v", reply)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func startTestNginx(t *testing.T, certificate tls.Certificate, primary, backup string) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
certFile, keyFile := filepath.Join(directory, "cert.pem"), filepath.Join(directory, "key.pem")
|
||||
writeProxyFile(t, certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}))
|
||||
key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeProxyFile(t, keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}))
|
||||
wrong, _ := testCertificate(t)
|
||||
wrongFile := filepath.Join(directory, "wrong.pem")
|
||||
writeProxyFile(t, wrongFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: wrong.Certificate[0]}))
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
backupLine := ""
|
||||
if backup != "" {
|
||||
backupLine = "server " + backup + " backup;"
|
||||
}
|
||||
config := fmt.Sprintf(`daemon off;
|
||||
master_process off;
|
||||
pid %q;
|
||||
error_log stderr info;
|
||||
events { worker_connections 128; }
|
||||
http {
|
||||
access_log off;
|
||||
client_body_temp_path %q;
|
||||
proxy_temp_path %q;
|
||||
fastcgi_temp_path %q;
|
||||
uwsgi_temp_path %q;
|
||||
scgi_temp_path %q;
|
||||
upstream origins { server %s max_fails=1 fail_timeout=30s; %s keepalive 16; }
|
||||
upstream badTrust { server %s; }
|
||||
upstream badName { server %s; }
|
||||
server {
|
||||
listen %s ssl;
|
||||
http2 on;
|
||||
ssl_certificate %q;
|
||||
ssl_certificate_key %q;
|
||||
proxy_http_version 2;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header X-Request-ID $http_x_request_id;
|
||||
proxy_ssl_verify on;
|
||||
proxy_ssl_trusted_certificate %q;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name localhost;
|
||||
proxy_connect_timeout 500ms;
|
||||
proxy_send_timeout 2s;
|
||||
proxy_read_timeout 10s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_next_upstream error timeout;
|
||||
proxy_next_upstream_tries 2;
|
||||
location / { proxy_pass https://origins; }
|
||||
location = /timeout { proxy_read_timeout 250ms; proxy_next_upstream off; proxy_pass https://origins; }
|
||||
location = /badTrust { proxy_ssl_trusted_certificate %q; proxy_next_upstream off; proxy_pass https://badTrust; }
|
||||
location = /badName { proxy_ssl_name wrong.invalid; proxy_next_upstream off; proxy_pass https://badName; }
|
||||
}
|
||||
}
|
||||
`, filepath.Join(directory, "nginx.pid"), filepath.Join(directory, "clientBody"), filepath.Join(directory, "proxyBody"),
|
||||
filepath.Join(directory, "fastcgi"), filepath.Join(directory, "uwsgi"), filepath.Join(directory, "scgi"),
|
||||
primary, backupLine, primary, primary, address, certFile, keyFile, certFile, wrongFile)
|
||||
configFile := filepath.Join(directory, "nginx.conf")
|
||||
writeProxyFile(t, configFile, []byte(config))
|
||||
logFile := filepath.Join(directory, "nginx.log")
|
||||
output, err := os.Create(logFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command("nginx", "-p", directory+"/", "-c", configFile)
|
||||
command.Stdout, command.Stderr = output, output
|
||||
if err := command.Start(); err != nil {
|
||||
_ = output.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
var processErr error
|
||||
go func() { processErr = command.Wait(); close(done) }()
|
||||
t.Cleanup(func() {
|
||||
_ = command.Process.Signal(syscall.SIGQUIT)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = command.Process.Kill()
|
||||
<-done
|
||||
t.Error("nginx did not exit after SIGQUIT")
|
||||
}
|
||||
_ = output.Close()
|
||||
if processErr != nil || t.Failed() {
|
||||
log, _ := os.ReadFile(logFile)
|
||||
t.Logf("nginx exit=%v log:\n%s", processErr, log)
|
||||
}
|
||||
if processErr != nil {
|
||||
t.Errorf("nginx exit: %v", processErr)
|
||||
}
|
||||
})
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("nginx exited during startup: %v", processErr)
|
||||
default:
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return "https://" + address
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("nginx did not start")
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeProxyFile(t *testing.T, path string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
497
transport/http2/server.go
Normal file
497
transport/http2/server.go
Normal file
@@ -0,0 +1,497 @@
|
||||
// Package http2 provides an opt-in HTTP/2 transport experiment.
|
||||
//
|
||||
// It owns the listener, TLS/ALPN setup, connection admission and global
|
||||
// request backpressure while using x/net/http2 for frame parsing and stream
|
||||
// flow control. It is deliberately separate from transport/native, whose
|
||||
// production preview remains HTTP/1.1.
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultShutdownTimeout = 10 * time.Second
|
||||
defaultTLSHandshake = 10 * time.Second
|
||||
defaultMaxBodyBytes = 8 << 20
|
||||
http2ALPNProtocol = "h2"
|
||||
)
|
||||
|
||||
// Server is an opt-in HTTP/2 transport. Run uses cleartext h2c for local
|
||||
// experiments; RunTLS or ServeTLS should be used for deployed TLS traffic.
|
||||
// HTTP/2 stream limits and global request admission are independent: a client
|
||||
// may open many streams, but only admitted handlers run concurrently.
|
||||
type Server struct {
|
||||
MaxConnections int
|
||||
MaxConcurrentRequests int
|
||||
MaxConcurrentStreams uint32
|
||||
MaxHeaderBytes int
|
||||
MaxBodyBytes int64
|
||||
IdleTimeout time.Duration
|
||||
TLSHandshakeTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
TLSConfig *tls.Config
|
||||
|
||||
mu sync.Mutex
|
||||
handler http.Handler
|
||||
listener net.Listener
|
||||
shutdownConfig *http.Server
|
||||
shutdownDone chan struct{}
|
||||
shutdownOnce sync.Once
|
||||
closing bool
|
||||
cancel context.CancelFunc
|
||||
conns map[net.Conn]struct{}
|
||||
protocolConns map[net.Conn]struct{}
|
||||
connSlots chan struct{}
|
||||
requestSlots chan struct{}
|
||||
accepted atomic.Uint64
|
||||
rejectedConns atomic.Uint64
|
||||
rejectedReqs atomic.Uint64
|
||||
protocolErrors atomic.Uint64
|
||||
goAwaySent atomic.Uint64
|
||||
rstStreams atomic.Uint64
|
||||
goAwayFrames atomic.Uint64
|
||||
activeConns atomic.Int64
|
||||
activeRequests atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// Stats is a point-in-time view of HTTP/2 transport admission and lifecycle.
|
||||
// Accepted and rejected counters are monotonic; active counters are gauges.
|
||||
type Stats struct {
|
||||
AcceptedConnections uint64 `json:"acceptedConnections"`
|
||||
RejectedConnections uint64 `json:"rejectedConnections"`
|
||||
RejectedRequests uint64 `json:"rejectedRequests"`
|
||||
ProtocolErrors uint64 `json:"protocolErrors"`
|
||||
GoAwaySent uint64 `json:"goAwaySent"`
|
||||
RSTStreams uint64 `json:"rstStreams"`
|
||||
GoAwayFrames uint64 `json:"goAwayFrames"`
|
||||
ActiveConnections int64 `json:"activeConnections"`
|
||||
ActiveRequests int64 `json:"activeRequests"`
|
||||
}
|
||||
|
||||
// New creates an HTTP/2 transport with the supplied handler. The handler may
|
||||
// also be supplied by App.RunWith through the Runner interface.
|
||||
func New(handler http.Handler) *Server {
|
||||
return &Server{
|
||||
handler: handler,
|
||||
conns: make(map[net.Conn]struct{}),
|
||||
MaxBodyBytes: defaultMaxBodyBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns a lock-free snapshot suitable for a metrics handler.
|
||||
func (s *Server) Stats() Stats {
|
||||
if s == nil {
|
||||
return Stats{}
|
||||
}
|
||||
return Stats{
|
||||
AcceptedConnections: s.accepted.Load(),
|
||||
RejectedConnections: s.rejectedConns.Load(),
|
||||
RejectedRequests: s.rejectedReqs.Load(),
|
||||
ProtocolErrors: s.protocolErrors.Load(),
|
||||
GoAwaySent: s.goAwaySent.Load(),
|
||||
RSTStreams: s.rstStreams.Load(),
|
||||
GoAwayFrames: s.goAwayFrames.Load(),
|
||||
ActiveConnections: s.activeConns.Load(),
|
||||
ActiveRequests: s.activeRequests.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run implements app.Runner. Without TLSConfig it starts an h2c listener for
|
||||
// local testing. Use RunTLS or TLSConfig for real HTTPS traffic.
|
||||
func (s *Server) Run(ctx context.Context, handler http.Handler, address string) error {
|
||||
if handler != nil {
|
||||
s.handler = handler
|
||||
}
|
||||
if s.handler == nil {
|
||||
return errors.New("go-omni/http2: nil handler")
|
||||
}
|
||||
if address == "" {
|
||||
address = ":8080"
|
||||
}
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.TLSConfig != nil {
|
||||
return s.ServeTLS(ctx, listener, s.TLSConfig)
|
||||
}
|
||||
return s.Serve(ctx, listener)
|
||||
}
|
||||
|
||||
// RunTLS loads a certificate pair and serves HTTP/2 over TLS with ALPN h2.
|
||||
func (s *Server) RunTLS(ctx context.Context, handler http.Handler, address, certFile, keyFile string) error {
|
||||
if handler != nil {
|
||||
s.handler = handler
|
||||
}
|
||||
if s.handler == nil {
|
||||
return errors.New("go-omni/http2: nil handler")
|
||||
}
|
||||
if address == "" {
|
||||
address = ":8443"
|
||||
}
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ServeTLS(ctx, listener, &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12})
|
||||
}
|
||||
|
||||
// ServeTLS wraps listener with TLS and requires ALPN h2. A caller-provided
|
||||
// config is cloned, so the original value is never mutated.
|
||||
func (s *Server) ServeTLS(ctx context.Context, listener net.Listener, config *tls.Config) error {
|
||||
if listener == nil {
|
||||
return errors.New("go-omni/http2: nil listener")
|
||||
}
|
||||
if config == nil {
|
||||
return errors.New("go-omni/http2: nil TLS config")
|
||||
}
|
||||
prepared, err := prepareTLSConfig(config)
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
return err
|
||||
}
|
||||
return s.Serve(ctx, tls.NewListener(listener, prepared))
|
||||
}
|
||||
|
||||
func prepareTLSConfig(config *tls.Config) (*tls.Config, error) {
|
||||
clone := config.Clone()
|
||||
if len(clone.NextProtos) > 0 {
|
||||
for _, protocol := range clone.NextProtos {
|
||||
if protocol != http2ALPNProtocol {
|
||||
return nil, fmt.Errorf("go-omni/http2: unsupported TLS ALPN protocol %q; only %q is supported", protocol, http2ALPNProtocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
clone.NextProtos = []string{http2ALPNProtocol}
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// Serve accepts h2c connections from listener until ctx is canceled.
|
||||
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
if listener == nil {
|
||||
return errors.New("go-omni/http2: nil listener")
|
||||
}
|
||||
if s.handler == nil {
|
||||
return errors.New("go-omni/http2: nil handler")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.cancel != nil {
|
||||
s.mu.Unlock()
|
||||
return errors.New("go-omni/http2: server is already running")
|
||||
}
|
||||
// Keep the connection context independent from the caller's context. A
|
||||
// canceled parent starts graceful shutdown; it must not cancel active
|
||||
// streams before the GOAWAY/drain phase has completed.
|
||||
baseCtx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.listener = listener
|
||||
s.shutdownDone = make(chan struct{})
|
||||
s.shutdownOnce = sync.Once{}
|
||||
s.closing = false
|
||||
if s.conns == nil {
|
||||
s.conns = make(map[net.Conn]struct{})
|
||||
}
|
||||
if s.protocolConns == nil {
|
||||
s.protocolConns = make(map[net.Conn]struct{})
|
||||
}
|
||||
if s.MaxConnections > 0 {
|
||||
s.connSlots = make(chan struct{}, s.MaxConnections)
|
||||
} else {
|
||||
s.connSlots = nil
|
||||
}
|
||||
if s.MaxConcurrentRequests > 0 {
|
||||
s.requestSlots = make(chan struct{}, s.MaxConcurrentRequests)
|
||||
} else {
|
||||
s.requestSlots = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
maxBodyBytes := s.MaxBodyBytes
|
||||
if maxBodyBytes <= 0 {
|
||||
maxBodyBytes = defaultMaxBodyBytes
|
||||
}
|
||||
requestHandler := http.Handler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.ContentLength > maxBodyBytes {
|
||||
_ = request.Body.Close()
|
||||
http.Error(writer, "request body too large\n", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, maxBodyBytes)
|
||||
s.handler.ServeHTTP(writer, request)
|
||||
}))
|
||||
gate := &requestGate{server: s, next: requestHandler, slots: s.requestSlots}
|
||||
protocolServer := &xhttp2.Server{
|
||||
MaxConcurrentStreams: s.MaxConcurrentStreams,
|
||||
IdleTimeout: s.IdleTimeout,
|
||||
CountError: func(string) {
|
||||
s.protocolErrors.Add(1)
|
||||
},
|
||||
}
|
||||
shutdownConfig := &http.Server{IdleTimeout: s.IdleTimeout, MaxHeaderBytes: s.MaxHeaderBytes}
|
||||
if err := xhttp2.ConfigureServer(shutdownConfig, protocolServer); err != nil {
|
||||
cancel()
|
||||
s.mu.Lock()
|
||||
s.listener = nil
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.shutdownConfig = shutdownConfig
|
||||
s.mu.Unlock()
|
||||
contextWakeDone := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Wake Accept. Serve performs the graceful drain synchronously so
|
||||
// its return value reports a shutdown timeout to the caller.
|
||||
_ = listener.Close()
|
||||
case <-contextWakeDone:
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
close(contextWakeDone)
|
||||
s.mu.Lock()
|
||||
closing := s.closing
|
||||
shutdownDone := s.shutdownDone
|
||||
s.listener = nil
|
||||
s.shutdownConfig = nil
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
if !closing {
|
||||
cancel()
|
||||
} else if shutdownDone != nil {
|
||||
<-shutdownDone
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil || s.isClosing() {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.accepted.Add(1)
|
||||
if slots := s.connSlots; slots != nil {
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
default:
|
||||
s.rejectedConns.Add(1)
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closing {
|
||||
s.mu.Unlock()
|
||||
if s.connSlots != nil {
|
||||
<-s.connSlots
|
||||
}
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
s.conns[conn] = struct{}{}
|
||||
s.activeConns.Add(1)
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
go s.serveConn(baseCtx, protocolServer, shutdownConfig, gate, conn)
|
||||
}
|
||||
if ctx.Err() != nil && !s.isClosing() {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), s.shutdownTimeout())
|
||||
err := s.Shutdown(shutdownCtx)
|
||||
shutdownCancel()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) shutdownTimeout() time.Duration {
|
||||
if s.ShutdownTimeout > 0 {
|
||||
return s.ShutdownTimeout
|
||||
}
|
||||
return defaultShutdownTimeout
|
||||
}
|
||||
|
||||
func (s *Server) serveConn(ctx context.Context, protocolServer *xhttp2.Server, baseConfig *http.Server, handler http.Handler, conn net.Conn) {
|
||||
defer s.wg.Done()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, conn)
|
||||
delete(s.protocolConns, conn)
|
||||
s.activeConns.Add(-1)
|
||||
s.mu.Unlock()
|
||||
if s.connSlots != nil {
|
||||
<-s.connSlots
|
||||
}
|
||||
_ = conn.Close()
|
||||
}()
|
||||
if tlsConn, ok := conn.(*tls.Conn); ok {
|
||||
_ = conn.SetDeadline(time.Now().Add(s.tlsHandshakeTimeout()))
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closing {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.protocolConns[conn] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
observed := &observedConn{
|
||||
Conn: conn,
|
||||
readParser: frameParser{prefaceRemain: 24},
|
||||
onFrame: func(direction frameDirection, frameType byte) {
|
||||
if direction == frameRead && frameType == frameTypeRSTStream {
|
||||
s.rstStreams.Add(1)
|
||||
}
|
||||
if direction == frameWrite && frameType == frameTypeGoAway {
|
||||
s.goAwayFrames.Add(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
var protocolConn net.Conn = observed
|
||||
if tlsConn, ok := conn.(*tls.Conn); ok {
|
||||
protocolConn = &observedTLSConn{observedConn: observed, state: tlsConn.ConnectionState()}
|
||||
}
|
||||
protocolServer.ServeConn(protocolConn, &xhttp2.ServeConnOpts{
|
||||
Context: ctx, Handler: handler, BaseConfig: baseConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) tlsHandshakeTimeout() time.Duration {
|
||||
if s.TLSHandshakeTimeout > 0 {
|
||||
return s.TLSHandshakeTimeout
|
||||
}
|
||||
return defaultTLSHandshake
|
||||
}
|
||||
|
||||
// Shutdown stops accepting connections and waits for active HTTP/2 sessions.
|
||||
// If the context expires, remaining connections are closed so handlers can
|
||||
// observe cancellation through their request contexts.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
timeout := s.ShutdownTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultShutdownTimeout
|
||||
}
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.cancel == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
alreadyClosing := s.closing
|
||||
s.closing = true
|
||||
listener := s.listener
|
||||
shutdownConfig := s.shutdownConfig
|
||||
cancel := s.cancel
|
||||
connections := make([]net.Conn, 0, len(s.conns))
|
||||
for conn := range s.conns {
|
||||
connections = append(connections, conn)
|
||||
}
|
||||
protocolConnectionCount := len(s.protocolConns)
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
_ = listener.Close()
|
||||
}
|
||||
// x/net/http2's configured shutdown callback sends GOAWAY and lets active
|
||||
// streams finish. The base request context remains live until the timeout
|
||||
// path below, where connections are forcibly closed.
|
||||
if shutdownConfig != nil && !alreadyClosing {
|
||||
s.goAwaySent.Add(uint64(protocolConnectionCount))
|
||||
_ = shutdownConfig.Shutdown(context.Background())
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
cancel()
|
||||
s.finishShutdown()
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
for _, conn := range connections {
|
||||
_ = conn.Close()
|
||||
}
|
||||
cancel()
|
||||
s.finishShutdown()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) finishShutdown() {
|
||||
s.mu.Lock()
|
||||
done := s.shutdownDone
|
||||
s.mu.Unlock()
|
||||
if done != nil {
|
||||
s.shutdownOnce.Do(func() { close(done) })
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) isClosing() bool {
|
||||
s.mu.Lock()
|
||||
closing := s.closing
|
||||
s.mu.Unlock()
|
||||
return closing
|
||||
}
|
||||
|
||||
type requestGate struct {
|
||||
server *Server
|
||||
next http.Handler
|
||||
slots chan struct{}
|
||||
}
|
||||
|
||||
func (g *requestGate) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if g.slots != nil {
|
||||
select {
|
||||
case g.slots <- struct{}{}:
|
||||
g.server.activeRequests.Add(1)
|
||||
defer func() {
|
||||
<-g.slots
|
||||
g.server.activeRequests.Add(-1)
|
||||
}()
|
||||
default:
|
||||
g.server.rejectedReqs.Add(1)
|
||||
http.Error(writer, "server busy\n", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
g.server.activeRequests.Add(1)
|
||||
defer g.server.activeRequests.Add(-1)
|
||||
}
|
||||
g.next.ServeHTTP(writer, request)
|
||||
}
|
||||
453
transport/http2/server_test.go
Normal file
453
transport/http2/server_test.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func TestServerHandlesH2CRequest(t *testing.T) {
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.ProtoMajor != 2 {
|
||||
http.Error(writer, "not HTTP/2", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write([]byte("ok"))
|
||||
}))
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
response, err := client.Get("http://" + address + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", response.StatusCode)
|
||||
}
|
||||
if stats := server.Stats(); stats.AcceptedConnections != 1 {
|
||||
t.Fatalf("unexpected HTTP/2 stats: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerEnforcesGlobalRequestLimitAndRecovers(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/hold" {
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
}
|
||||
_, _ = writer.Write([]byte("ok"))
|
||||
}))
|
||||
server.MaxConcurrentRequests = 1
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
firstDone := make(chan *http.Response, 1)
|
||||
firstErr := make(chan error, 1)
|
||||
go func() {
|
||||
response, err := client.Get("http://" + address + "/hold")
|
||||
if err != nil {
|
||||
firstErr <- err
|
||||
return
|
||||
}
|
||||
firstDone <- response
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case err := <-firstErr:
|
||||
t.Fatal(err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first HTTP/2 stream did not start")
|
||||
}
|
||||
|
||||
response, err := client.Get("http://" + address + "/busy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 at stream limit, got %d", response.StatusCode)
|
||||
}
|
||||
if stats := server.Stats(); stats.RejectedRequests != 1 {
|
||||
t.Fatalf("expected one rejected stream, got %+v", stats)
|
||||
}
|
||||
|
||||
close(release)
|
||||
select {
|
||||
case response = <-firstDone:
|
||||
response.Body.Close()
|
||||
case err := <-firstErr:
|
||||
t.Fatal(err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("held HTTP/2 stream did not finish")
|
||||
}
|
||||
response, err = client.Get("http://" + address + "/recovered")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("request limit did not recover, got %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRejectsBodyAboveLimitAndRecovers(t *testing.T) {
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write(body)
|
||||
}))
|
||||
server.MaxBodyBytes = 4
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, "http://"+address+"/too-large", bytes.NewReader([]byte("12345")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("expected 413 for oversized body, got %d", response.StatusCode)
|
||||
}
|
||||
|
||||
request, err = http.NewRequest(http.MethodPost, "http://"+address+"/ok", bytes.NewReader([]byte("1234")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err = client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "1234" {
|
||||
t.Fatalf("body limit did not recover: status=%d body=%q err=%v", response.StatusCode, body, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRejectsHeadersAboveLimitAndRecovers(t *testing.T) {
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = io.WriteString(writer, "ok")
|
||||
}))
|
||||
server.MaxHeaderBytes = 1024
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, "http://"+address+"/too-large", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("X-Large", strings.Repeat("x", 2048))
|
||||
response, err := client.Do(request)
|
||||
if err == nil {
|
||||
response.Body.Close()
|
||||
if response.StatusCode < http.StatusBadRequest {
|
||||
t.Fatalf("oversized headers unexpectedly succeeded with status %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
response, err = client.Get("http://" + address + "/recovered")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "ok" {
|
||||
t.Fatalf("header limit did not recover: status=%d body=%q err=%v", response.StatusCode, body, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerMultiplexes128Streams(t *testing.T) {
|
||||
started := make(chan struct{}, 128)
|
||||
release := make(chan struct{})
|
||||
var active, maximum atomic.Int32
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
current := active.Add(1)
|
||||
for {
|
||||
seen := maximum.Load()
|
||||
if current <= seen || maximum.CompareAndSwap(seen, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
active.Add(-1)
|
||||
_, _ = io.WriteString(writer, request.URL.Path)
|
||||
}))
|
||||
server.MaxConcurrentStreams = 128
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
StrictMaxConcurrentStreams: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
type multiplexedResponse struct {
|
||||
response *http.Response
|
||||
expected string
|
||||
}
|
||||
responses := make(chan multiplexedResponse, 128)
|
||||
errorsCh := make(chan error, 128)
|
||||
for i := 0; i < 128; i++ {
|
||||
go func(i int) {
|
||||
response, err := client.Get(fmt.Sprintf("http://%s/stream-%d", address, i))
|
||||
if err != nil {
|
||||
errorsCh <- err
|
||||
return
|
||||
}
|
||||
responses <- multiplexedResponse{response: response, expected: fmt.Sprintf("/stream-%d", i)}
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 128; i++ {
|
||||
select {
|
||||
case <-started:
|
||||
case err := <-errorsCh:
|
||||
close(release)
|
||||
t.Fatal(err)
|
||||
case <-time.After(5 * time.Second):
|
||||
close(release)
|
||||
t.Fatal("HTTP/2 streams did not reach the handler")
|
||||
}
|
||||
}
|
||||
if got := maximum.Load(); got < 128 {
|
||||
close(release)
|
||||
t.Fatalf("expected 128 concurrent streams, got %d", got)
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveConnections != 1 {
|
||||
close(release)
|
||||
t.Fatalf("expected one multiplexed connection, got %+v", stats)
|
||||
}
|
||||
close(release)
|
||||
for i := 0; i < 128; i++ {
|
||||
select {
|
||||
case result := <-responses:
|
||||
body, err := io.ReadAll(result.response.Body)
|
||||
result.response.Body.Close()
|
||||
if err != nil || result.response.StatusCode != http.StatusOK || string(body) != result.expected {
|
||||
t.Fatalf("unexpected multiplexed response: status=%d body=%q expected=%q err=%v", result.response.StatusCode, body, result.expected, err)
|
||||
}
|
||||
case err := <-errorsCh:
|
||||
t.Fatal(err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("HTTP/2 streams did not finish")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerStreamsLargeBodyWithFlowControl(t *testing.T) {
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
var total int64
|
||||
buffer := make([]byte, 16<<10)
|
||||
for {
|
||||
n, err := request.Body.Read(buffer)
|
||||
total += int64(n)
|
||||
if n > 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, "%d", total)
|
||||
}))
|
||||
server.MaxBodyBytes = 4 << 20
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
transport := &xhttp2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
payload := bytes.Repeat([]byte("x"), 2<<20)
|
||||
request, err := http.NewRequest(http.MethodPost, "http://"+address+"/large", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != "2097152" {
|
||||
t.Fatalf("large body flow-control result: status=%d body=%q err=%v", response.StatusCode, body, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTLSUsesH2ALPN(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.TLS == nil || request.TLS.NegotiatedProtocol != "h2" {
|
||||
http.Error(writer, "missing h2 ALPN", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
|
||||
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}()
|
||||
stop := func() {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
defer stop()
|
||||
|
||||
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
}}
|
||||
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
|
||||
defer transport.CloseIdleConnections()
|
||||
response, err := client.Get("https://" + listener.Addr().String() + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("unexpected TLS status: %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func startServer(t *testing.T, server *Server) (string, func()) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- server.Serve(context.Background(), listener) }()
|
||||
stop := func() {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
return listener.Addr().String(), stop
|
||||
}
|
||||
|
||||
func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
DNSNames: []string{"localhost"},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate := tls.Certificate{
|
||||
Certificate: [][]byte{der},
|
||||
PrivateKey: key,
|
||||
}
|
||||
certificate.Leaf = leaf
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
return certificate, roots
|
||||
}
|
||||
220
transport/http2/soak_test.go
Normal file
220
transport/http2/soak_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// TestTLSSoak is short by default so it remains part of normal package
|
||||
// verification. Linux acceptance runs set GO_OMNI_HTTP2_SOAK_DURATION=30m.
|
||||
func TestTLSSoak(t *testing.T) {
|
||||
started := time.Now()
|
||||
duration := soakDuration(t)
|
||||
logSoakResources(t, started, "before", 0, 0, Stats{})
|
||||
|
||||
certificate, roots := testCertificate(t)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/slow":
|
||||
timer := time.NewTimer(10 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
}
|
||||
case "/body":
|
||||
if _, err := io.Copy(io.Discard, request.Body); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
writer.Header().Set("X-Soak-Request-Id", request.Header.Get("X-Soak-Request-Id"))
|
||||
_, _ = io.WriteString(writer, request.URL.Path)
|
||||
}))
|
||||
server.MaxConnections = 8
|
||||
server.MaxConcurrentRequests = 128
|
||||
server.MaxConcurrentStreams = 128
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
|
||||
Certificates: []tls.Certificate{certificate},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}()
|
||||
|
||||
transport := &xhttp2.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots,
|
||||
ServerName: "localhost",
|
||||
MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
},
|
||||
StrictMaxConcurrentStreams: true,
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
|
||||
var requests, failures atomic.Uint64
|
||||
var firstFailure string
|
||||
var failureOnce sync.Once
|
||||
recordFailure := func(message string) {
|
||||
failures.Add(1)
|
||||
failureOnce.Do(func() { firstFailure = message })
|
||||
}
|
||||
monitorStop, monitorDone := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(monitorDone)
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
logSoakResources(t, started, "running", requests.Load(), failures.Load(), server.Stats())
|
||||
case <-monitorStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
close(monitorStop)
|
||||
<-monitorDone
|
||||
}()
|
||||
deadline := time.Now().Add(duration)
|
||||
var workers sync.WaitGroup
|
||||
for worker := 0; worker < 128; worker++ {
|
||||
workers.Add(1)
|
||||
go func(worker int) {
|
||||
defer workers.Done()
|
||||
for sequence := uint64(0); time.Now().Before(deadline); sequence++ {
|
||||
path := "/health"
|
||||
method := http.MethodGet
|
||||
var body io.Reader
|
||||
if worker%32 == 0 {
|
||||
path = "/slow"
|
||||
}
|
||||
if worker%64 == 0 {
|
||||
path = "/body"
|
||||
method = http.MethodPost
|
||||
body = &repeatingReader{remaining: 64 << 10}
|
||||
}
|
||||
requestContext, cancelRequest := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
request, err := http.NewRequestWithContext(requestContext, method, "https://"+listener.Addr().String()+path, body)
|
||||
if err != nil {
|
||||
cancelRequest()
|
||||
recordFailure(err.Error())
|
||||
continue
|
||||
}
|
||||
requestID := strconv.Itoa(worker) + ":" + strconv.FormatUint(sequence, 10)
|
||||
request.Header.Set("X-Soak-Request-Id", requestID)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
cancelRequest()
|
||||
recordFailure(err.Error())
|
||||
continue
|
||||
}
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||
response.Body.Close()
|
||||
cancelRequest()
|
||||
if readErr != nil {
|
||||
recordFailure(readErr.Error())
|
||||
continue
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || string(responseBody) != path || response.Header.Get("X-Soak-Request-Id") != requestID {
|
||||
recordFailure("unexpected status, protocol, body or request ID: " + response.Status)
|
||||
continue
|
||||
}
|
||||
requests.Add(1)
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
workers.Wait()
|
||||
transport.CloseIdleConnections()
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
shutdownErr := server.Shutdown(shutdownContext)
|
||||
cancel()
|
||||
if shutdownErr != nil {
|
||||
t.Fatalf("TLS soak shutdown: %v", shutdownErr)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("TLS soak serve: %v", err)
|
||||
}
|
||||
logSoakResources(t, started, "after", requests.Load(), failures.Load(), server.Stats())
|
||||
if failures.Load() != 0 || requests.Load() == 0 {
|
||||
t.Fatalf("TLS soak requests=%d failures=%d firstFailure=%q stats=%+v", requests.Load(), failures.Load(), firstFailure, server.Stats())
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 {
|
||||
t.Fatalf("TLS soak protocol errors, unexpected rejection or unreclaimed resources: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func soakDuration(t *testing.T) time.Duration {
|
||||
t.Helper()
|
||||
if value := os.Getenv("GO_OMNI_HTTP2_SOAK_DURATION"); value != "" {
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil || duration <= 0 {
|
||||
t.Fatalf("invalid GO_OMNI_HTTP2_SOAK_DURATION %q: %v", value, err)
|
||||
}
|
||||
return duration
|
||||
}
|
||||
return 3 * time.Second
|
||||
}
|
||||
|
||||
// These measurements cover the client and server in the same test process.
|
||||
// RSS includes race runtime allocations that do not appear in Go heap stats.
|
||||
func logSoakResources(t *testing.T, started time.Time, phase string, requests, failures uint64, stats Stats) {
|
||||
t.Helper()
|
||||
var memory runtime.MemStats
|
||||
runtime.ReadMemStats(&memory)
|
||||
rssBytes, fileDescriptors := int64(-1), -1
|
||||
if data, err := os.ReadFile("/proc/self/statm"); err == nil {
|
||||
if fields := strings.Fields(string(data)); len(fields) >= 2 {
|
||||
if pages, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||||
rssBytes = pages * int64(os.Getpagesize())
|
||||
}
|
||||
}
|
||||
}
|
||||
if entries, err := os.ReadDir("/proc/self/fd"); err == nil {
|
||||
fileDescriptors = len(entries)
|
||||
}
|
||||
t.Logf("phase=%s elapsed=%s requests=%d failures=%d heapBytes=%d rssBytes=%d goroutines=%d fileDescriptors=%d stats=%+v",
|
||||
phase, time.Since(started).Round(time.Millisecond), requests, failures,
|
||||
memory.HeapAlloc, rssBytes, runtime.NumGoroutine(), fileDescriptors, stats)
|
||||
}
|
||||
|
||||
type repeatingReader struct {
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (r *repeatingReader) Read(buffer []byte) (int, error) {
|
||||
if r.remaining == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(buffer) > r.remaining {
|
||||
buffer = buffer[:r.remaining]
|
||||
}
|
||||
for index := range buffer {
|
||||
buffer[index] = 'x'
|
||||
}
|
||||
r.remaining -= len(buffer)
|
||||
return len(buffer), nil
|
||||
}
|
||||
|
||||
func (r *repeatingReader) Close() error { return nil }
|
||||
45
transport/native/HTTP2.md
Normal file
45
transport/native/HTTP2.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# native HTTP/2 进入条件
|
||||
|
||||
当前 `transport/native` 是经过 Linux 长连接和反向代理验收的 HTTP/1.1 实现。HTTP/2 暂不直接打开,原因是它把一个 TCP 连接变成多个独立请求流,连接级的 Keep-Alive、超时和并发限制不能直接复用现有 HTTP/1.1 状态机。
|
||||
|
||||
当前已提供独立的 `transport/http2` 实验 Runner。它已经验证 TLS/h2、h2c、本地连接和请求闸门、GOAWAY 排空以及 RST_STREAM 取消;这些结果用于验证生命周期设计,不代表 native 已经启用 HTTP/2。实验 Runner 还覆盖了默认 1 MiB 头部上限、8 MiB 请求体上限、128 路同连接 stream 多路复用,以及 2 MiB 慢读取 body 在窗口耗尽后的恢复。`TestTLSSoak` 默认运行 3 秒,Linux 长测通过 `python3 scripts/verifyHTTP2.py --duration 30m` 启动。
|
||||
|
||||
2026-09-23 已在 Linux amd64 完成单 TLS 连接、128 个 worker 的 30 分钟长测:58,102,476 次请求,0 失败与协议错误,采样 RSS 峰值约 22.6 MiB,退出后连接、活动请求和 FD 回收。测试容器限制为 2 核、2 GiB,测量包含同一进程中的客户端与服务端,见 [报告](../../performance/http2SoakLinux.json)。该场景包含慢 Handler 与 64 KiB body。
|
||||
|
||||
同日完成多连接慢客户端混合场景:8 条独立 TLS 连接,每条 14 个普通请求 worker、1 个慢上传 worker、1 个慢下载 worker,持续 30 分钟。256 KiB 上传每 4 KiB 等待 2 ms;8 MiB 下载每次最多读取 64 KiB 后等待 20 ms。共完成 53,191,687 次请求,其中慢上传 106,971 次、慢下载 5,588 次,0 失败与协议错误;每条连接均覆盖三类请求且没有重连。采样 RSS 峰值约 116.6 MiB,包含客户端、服务端与下载缓冲;退出后活动连接/请求归零、goroutine 回到 2、FD 回到 6,释放测试数据并两次 GC 后存活堆约 450 KiB。运行命令为 `python3 scripts/verifyHTTP2.py --scenario mixed --duration 30m`,完整采样见 [混合长测报告](../../performance/http2MixedSoakLinux.json)。8 MiB 下载超过客户端的 stream 初始窗口,需要窗口更新,但不代表耗尽了连接级窗口;后者由下述独立帧测试验证。Nginx HTTP/2 回源联调结果见下方。
|
||||
|
||||
连接级流控及关闭边界测试已通过 Linux amd64 的 HTTP/2 全包 race、vet、build 回归,测试提交、执行命令和原始日志校验值见 [边界回归报告](../../performance/http2FlowControlLinux.json)。
|
||||
|
||||
Nginx 1.30.5 的真实 HTTP/2 回源联调也已通过:普通与 race 模式各 3 轮,覆盖两段 TLS/h2、证书拒绝、连接复用、超时/取消恢复和 GOAWAY 下的新旧实例切换。完整配置边界、运行方法与报告见 [HTTP/2 代理回源验收](../http2/PROXY.md)。
|
||||
|
||||
## 目标边界
|
||||
|
||||
- HTTP/2 必须作为显式 opt-in 协议,不改变 `omni.New()`、`transport/nethttp` 或 native HTTP/1.1 的默认行为。
|
||||
- TLS ALPN 只在 HTTP/2 实现通过全部验收后增加 `h2`;当前 native 只宣告 `http/1.1`,误配 `h2` 会在启动时失败。
|
||||
- Handler、Service、统一响应、requestId、可信代理和业务日志继续复用现有 `httpx.Context` 与 `http.Handler` 边界。
|
||||
- h2c 只用于本地测试,生产入口使用 TLS + ALPN;不能把明文升级路径默认为公网协议。
|
||||
|
||||
## 连接和流的生命周期
|
||||
|
||||
1. accept loop 先执行连接上限检查,再执行 TLS 握手;握手受独立超时约束,不能占满连接槽位后无限等待。
|
||||
2. TLS 协商得到 `h2` 后进入 HTTP/2 connection state;其他 ALPN 直接关闭,不能把 HTTP/2 preface 交给 HTTP/1.1 解析器。
|
||||
3. 每个 stream 创建独立的 `http.Request` 和取消 context。`RST_STREAM` 只取消当前请求,TCP 断开才取消该连接上的所有请求。
|
||||
4. `MaxConcurrentRequests` 是所有连接共享的全局请求闸门;`MaxConcurrentStreams` 限制单连接并发流;两者都必须在收到完整请求体前生效。
|
||||
5. `MaxHeaderBytes` 沿用标准库默认 1 MiB 上限,`MaxBodyBytes` 默认限制请求体为 8 MiB;已声明超限的请求在进入业务 handler 前返回 413,未声明长度的 body 通过受限 reader 读取,避免业务层无意间把整个请求体加载到内存。
|
||||
6. 服务关闭时停止 accept,发送 GOAWAY 拒绝新流,等待活动流排空;超过 `ShutdownTimeout` 后关闭连接并取消剩余请求。实验 Runner 已覆盖这条顺序。
|
||||
|
||||
## 必须可观测的状态
|
||||
|
||||
HTTP/2 版本需要在 native `Stats` 之外至少记录:活动 stream、被拒绝 stream、连接级 protocol error、RST_STREAM、GOAWAY 发送次数,以及因流控或并发闸门触发的拒绝。实验 Runner 当前已通过 `ActiveRequests`、`RejectedRequests`、`ProtocolErrors`、`RSTStreams`、`GoAwaySent` 和 `GoAwayFrames` 暴露活动/拒绝/协议错误/客户端重置/优雅关闭快照;帧观察器只解析 9 字节头部并跳过 payload,不会缓存大 body。计数必须是原子快照,不能在 metrics handler 中锁住连接状态机。
|
||||
|
||||
## 进入实现前的验收矩阵
|
||||
|
||||
- TLS ALPN:只协商 `h2`,HTTP/1.1 客户端仍由现有 native 路径处理;不支持的 ALPN 在握手前后都不会误入错误解析器。
|
||||
- 多路复用:一个连接并发至少 128 个 stream,响应和 requestId 不串线,在流与连接级发送额度充足时,单个慢 stream 不阻塞其他可发送 stream(实验 Runner 已覆盖 128 路同连接 stream)。
|
||||
- 流控:慢读取、慢写、窗口耗尽和大 body 都能恢复;内存不会随未消费 body 无限增长。实验 Runner 已覆盖 2 MiB 慢读取 body 和请求体上限,以及客户端将 stream 窗口设为零时的大响应阻塞、同连接 204、其他连接带 body 响应、WINDOW_UPDATE 恢复发送和 RST_STREAM 取消回收;连接级独立测试将初始 stream 窗口设为 128 KiB,仅消耗默认 65,535 字节连接窗口,确认暂停与隔离,连接级 WINDOW_UPDATE 后恢复并复用连接;持续慢客户端混合长测也已通过。连接级窗口耗尽时,同连接的其他 DATA 也受该窗口约束,HEADERS 和其他 TCP 连接仍可继续。
|
||||
- 取消:RST_STREAM、客户端断开、服务关闭分别只影响应受影响的请求,并能在 metrics 中看到回收;当前实验 Runner 已覆盖 RST_STREAM 与服务关闭,包括客户端零接收窗口造成写阻塞时,关闭超时强制解除写阻塞、取消请求并回收连接。
|
||||
- 背压:连接上限、全局 stream 上限、单连接 stream 上限和 header/body 上限均能快速拒绝并自动恢复。实验 Runner 已增加 TLS 未完成握手占满连接槽位时的及时拒绝、握手超时回收及后续正常 HTTPS 请求恢复验收。
|
||||
- 代理联调:Nginx 的 TLS/h2 回源、Keep-Alive、超时/取消及 GOAWAY 排空已由 `verifyHTTP2Proxy.py` 验证。旧实例处理中的 POST 能正常完成,新请求转入备用实例,旧实例连接与请求归零。此测试使用单进程 Nginx,未验证 Nginx master/worker 重载或 Cloudflare 实网部署。`verifyProxy.py` 继续承担 native HTTP/1.1 TLS 回源验收。
|
||||
- 长测:Linux amd64 至少 30 分钟 TLS、多连接、多 stream、慢客户端混合场景;要求 0 协议错误、0 请求串线,连接、stream、goroutine 和 FD 在停止后回收。单连接场景与 8 连接慢上传/慢下载混合场景均已通过,原始日志与采样分别保存在两个报告中。
|
||||
|
||||
统一启动配置已接入 `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。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") })
|
||||
|
||||
101
transport/native/shutdown_test.go
Normal file
101
transport/native/shutdown_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user