fix: bound HTTP2 soak resources and cleanup
This commit is contained in:
@@ -5,7 +5,8 @@
|
|||||||
- 新增独立 `transport/http2` 实验 Runner:支持 h2c/TLS h2、连接上限、全局请求并发闸门、GOAWAY 排空、RST_STREAM 单流取消和生命周期统计;不改变 native HTTP/1.1 默认路径。
|
- 新增独立 `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 实验 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 TLS soak:默认 3 秒,Linux 验收可通过 `GO_OMNI_HTTP2_SOAK_DURATION=30m` 覆盖,混合验证 128 路 stream、慢 handler、请求体、Keep-Alive 和退出回收。
|
||||||
- 新增 `scripts/verifyHTTP2.py`,固定在 Go 1.25 Linux 容器中运行 HTTP/2 TLS race 长测。
|
- HTTP/2 soak 每 15 秒输出请求/失败数、Go 堆、Linux RSS、goroutine、FD 和协议统计;校验响应协议、内容和独立请求 ID,不再忽略压测期限附近已发出请求的失败。
|
||||||
|
- 新增 `scripts/verifyHTTP2.py`,固定在 Go 1.25 Linux 容器中运行 HTTP/2 TLS 长测;默认使用普通模式,`--race` 默认运行 60 秒。容器默认限制为 2 核、2 GiB 内存、256 个进程/线程,带总时限、中断清理和日志大小限制。
|
||||||
- native TLS 明确限制 ALPN 为 `http/1.1`:缺省自动宣告该协议,误配置 `h2` 等未实现协议时在启动阶段报错。
|
- native TLS 明确限制 ALPN 为 `http/1.1`:缺省自动宣告该协议,误配置 `h2` 等未实现协议时在启动阶段报错。
|
||||||
- native `Server.Stats()` 新增连接和请求过载拒绝计数;新增可信代理配置,访问日志只在 socket 对端属于配置网段时解析 `X-Forwarded-For`。
|
- native `Server.Stats()` 新增连接和请求过载拒绝计数;新增可信代理配置,访问日志只在 socket 对端属于配置网段时解析 `X-Forwarded-For`。
|
||||||
- 新增 transport-neutral `metrics` 包,支持请求数、错误数、in-flight、累计耗时和状态分布,并可直接注册 `/metrics` 接口。
|
- 新增 transport-neutral `metrics` 包,支持请求数、错误数、in-flight、累计耗时和状态分布,并可直接注册 `/metrics` 接口。
|
||||||
|
|||||||
@@ -228,7 +228,9 @@ python3 scripts/verifyBusiness.py --version v0.6.0-preview
|
|||||||
|
|
||||||
标准库与 native 的并发、延迟和长连接内存对照见 [PERFORMANCE.md](PERFORMANCE.md)。`python3 scripts/verifyPerformance.py` 运行检查与 benchmark;增加 `--load --output /tmp/goOmniPerf.json` 可测量独立服务进程并保存报告。
|
标准库与 native 的并发、延迟和长连接内存对照见 [PERFORMANCE.md](PERFORMANCE.md)。`python3 scripts/verifyPerformance.py` 运行检查与 benchmark;增加 `--load --output /tmp/goOmniPerf.json` 可测量独立服务进程并保存报告。
|
||||||
|
|
||||||
HTTP/2 仍是显式 opt-in 实验适配器;Linux TLS 长测使用 `python3 scripts/verifyHTTP2.py --duration 30m`,不会改变 native 的 HTTP/1.1 默认路径。
|
HTTP/2 仍是显式 opt-in 实验适配器,不会改变 native 的 HTTP/1.1 默认路径。Linux TLS 长测使用 `python3 scripts/verifyHTTP2.py --duration 30m`,默认使用普通 Go 运行模式;`--race` 默认改为 60 秒,用于并发检查,其额外内存开销不能当成生产运行基线。
|
||||||
|
|
||||||
|
该脚本默认把测试容器限制为 2 核、2 GiB 内存(禁用 swap)、256 个进程/线程,整个命令最多运行 40 分钟,中断和超时会清理本次容器。这些限制可用 `--cpus`、`--memory-mb`、`--pids-limit`、`--wall-timeout` 调整;共享服务器应先用 `--duration 60s` 短测,再逐步延长。每 15 秒输出的堆、RSS 和 FD 统计包含同一进程内的客户端与服务端;当前验证范围见 [Linux HTTP/2 短测记录](performance/http2SmokeLinux.json)。
|
||||||
|
|
||||||
在框架根目录运行:
|
在框架根目录运行:
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,8 @@
|
|||||||
- native `Stats()` 已记录连接和请求过载拒绝数;访问日志已支持可信代理网段配置,默认拒绝伪造的 `X-Forwarded-For`。
|
- native `Stats()` 已记录连接和请求过载拒绝数;访问日志已支持可信代理网段配置,默认拒绝伪造的 `X-Forwarded-For`。
|
||||||
- native TLS 已明确只宣告 HTTP/1.1 ALPN;HTTP/2 暂由边缘代理终止或使用 `transport/nethttp`,已整理独立协议设计、流控和压测门槛,见 [native HTTP/2 进入条件](transport/native/HTTP2.md),完成后再接入 native。
|
- 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` 作为临时帧/流控实现,尚未达到 native 生产协议的流控和长测门槛。
|
- 已加入独立 `transport/http2` 实验 Runner,验证 h2c/TLS h2、连接上限、全局请求并发拒绝和恢复,以及 GOAWAY 排空、RST_STREAM 单流取消和服务关闭超时;它使用 `x/net/http2` 作为临时帧/流控实现,尚未达到 native 生产协议的流控和长测门槛。
|
||||||
- HTTP/2 实验已补齐头部/请求体上限、128 路同连接多路复用、慢读取流控、协议错误、RST_STREAM 与 GOAWAY 统计,并提供 `scripts/verifyHTTP2.py --duration 30m` 的 Linux TLS 长测入口;代理联调和长测结果仍是进入 native 前的门槛。
|
- HTTP/2 实验已补齐头部/请求体上限、128 路同连接多路复用、慢读取流控、协议错误、RST_STREAM 与 GOAWAY 统计,并提供 `scripts/verifyHTTP2.py --duration 30m` 的 Linux TLS 长测入口;长测默认不启用 race,短时并发检查使用 `--race`(默认 60 秒)。测试容器默认限制为 2 核、2 GiB 内存、256 个进程/线程,并设总时限和退出清理;代理联调和长测结果仍是进入 native 前的门槛。
|
||||||
|
- 新 Linux amd64 环境在上述硬上限内完成 HTTP/2 普通模式 2 分钟与 race 模式 60 秒短测,分别为 3,885,273 和 377,788 次请求,均为 0 失败;连接/请求归零、FD 回到基线。SIGTERM、SIGHUP 和总时限退出清理已实测。报告见 [HTTP/2 短测记录](performance/http2SmokeLinux.json);旧的无资源上限 race 长测因 RSS 增长被停止,30 分钟 HTTP/2 验收仍未完成。
|
||||||
- 已用 16 个并发 worker 完成 3,200 次建连/断连 race 验收;MaxConnections 满载拒绝、槽位释放和 TLS 连接上限均已覆盖。
|
- 已用 16 个并发 worker 完成 3,200 次建连/断连 race 验收;MaxConnections 满载拒绝、槽位释放和 TLS 连接上限均已覆盖。
|
||||||
- 新增 `scripts/verifyLinux.py`,固定在 Go 1.25 Linux 容器内运行 race、vet、build 和可选 TLS/非 TLS perfcheck;perfcheck 记录堆、goroutine、连接和 Linux 文件描述符。
|
- 新增 `scripts/verifyLinux.py`,固定在 Go 1.25 Linux 容器内运行 race、vet、build 和可选 TLS/非 TLS perfcheck;perfcheck 记录堆、goroutine、连接和 Linux 文件描述符。
|
||||||
- Linux/arm64 已完成 30 秒、128 并发、512 空闲连接的 TLS 与非 TLS 持续回归,报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`,均为 0 错误且连接、goroutine、FD 回收正常。
|
- Linux/arm64 已完成 30 秒、128 并发、512 空闲连接的 TLS 与非 TLS 持续回归,报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`,均为 0 错误且连接、goroutine、FD 回收正常。
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
@@ -2,42 +2,90 @@
|
|||||||
"""Run the opt-in HTTP/2 TLS soak in a pinned Linux Go image."""
|
"""Run the opt-in HTTP/2 TLS soak in a pinned Linux Go image."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--image", default="golang:1.25")
|
parser.add_argument("--image", default="golang:1.25")
|
||||||
parser.add_argument("--goproxy", default="", help="optional Go module proxy passed to the container")
|
parser.add_argument("--goproxy", default="", help="optional Go module proxy passed to the container")
|
||||||
parser.add_argument("--duration", default="30m")
|
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("--timeout", default="35m", help="go test timeout (default: 35m)")
|
||||||
parser.add_argument("--gomaxprocs", type=int, default=2)
|
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()
|
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:
|
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")
|
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")
|
docker = shutil.which("docker")
|
||||||
if docker is None:
|
if docker is None:
|
||||||
raise SystemExit("docker is required for HTTP/2 Linux soak")
|
raise SystemExit("docker is required for HTTP/2 Linux soak")
|
||||||
root = Path(__file__).resolve().parent.parent
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
name = "go-omni-http2-soak-" + uuid.uuid4().hex[:12]
|
||||||
command = [
|
command = [
|
||||||
docker, "run", "--rm", "--pull=missing",
|
docker, "run", "--rm", "--pull=missing", "--name", name,
|
||||||
"-v", f"{root}:/workspace", "-w", "/workspace",
|
"--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", "GOWORK=off", "-e", f"GOMAXPROCS={args.gomaxprocs}",
|
||||||
"-e", f"GO_OMNI_HTTP2_SOAK_DURATION={args.duration}",
|
"-e", f"GO_OMNI_HTTP2_SOAK_DURATION={args.duration}",
|
||||||
]
|
]
|
||||||
if args.goproxy:
|
if args.goproxy:
|
||||||
command += ["-e", f"GOPROXY={args.goproxy}"]
|
command += ["-e", f"GOPROXY={args.goproxy}"]
|
||||||
|
command += [args.image, "go", "test"]
|
||||||
|
if args.race:
|
||||||
|
command.append("-race")
|
||||||
command += [
|
command += [
|
||||||
args.image, "go", "test", "-race", "-timeout", args.timeout, "./transport/http2",
|
"-v", "-timeout", args.timeout, "./transport/http2", "-run", "^TestTLSSoak$", "-count=1",
|
||||||
"-run", "^TestTLSSoak$", "-count=1",
|
|
||||||
]
|
]
|
||||||
print(f"HTTP/2 Linux soak image: {args.image}", flush=True)
|
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 duration: {args.duration}", flush=True)
|
||||||
result = subprocess.run(command, cwd=root)
|
print(f"HTTP/2 TLS soak mode: {'race' if args.race else 'regular'}", 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:
|
if result.returncode != 0:
|
||||||
raise SystemExit(f"HTTP/2 Linux soak failed with exit code {result.returncode}")
|
raise SystemExit(f"HTTP/2 Linux soak failed with exit code {result.returncode}")
|
||||||
print("PASS HTTP/2 TLS soak", flush=True)
|
print("PASS HTTP/2 TLS soak", flush=True)
|
||||||
|
|||||||
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()
|
||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,6 +21,7 @@ import (
|
|||||||
// TestTLSSoak is short by default so it remains part of normal package
|
// TestTLSSoak is short by default so it remains part of normal package
|
||||||
// verification. Linux acceptance runs set GO_OMNI_HTTP2_SOAK_DURATION=30m.
|
// verification. Linux acceptance runs set GO_OMNI_HTTP2_SOAK_DURATION=30m.
|
||||||
func TestTLSSoak(t *testing.T) {
|
func TestTLSSoak(t *testing.T) {
|
||||||
|
started := time.Now()
|
||||||
duration := 3 * time.Second
|
duration := 3 * time.Second
|
||||||
if value := os.Getenv("GO_OMNI_HTTP2_SOAK_DURATION"); value != "" {
|
if value := os.Getenv("GO_OMNI_HTTP2_SOAK_DURATION"); value != "" {
|
||||||
parsed, err := time.ParseDuration(value)
|
parsed, err := time.ParseDuration(value)
|
||||||
@@ -26,6 +30,7 @@ func TestTLSSoak(t *testing.T) {
|
|||||||
}
|
}
|
||||||
duration = parsed
|
duration = parsed
|
||||||
}
|
}
|
||||||
|
logSoakResources(t, started, "before", 0, 0, Stats{})
|
||||||
|
|
||||||
certificate, roots := testCertificate(t)
|
certificate, roots := testCertificate(t)
|
||||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -44,6 +49,7 @@ func TestTLSSoak(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
writer.Header().Set("X-Soak-Request-Id", request.Header.Get("X-Soak-Request-Id"))
|
||||||
_, _ = io.WriteString(writer, request.URL.Path)
|
_, _ = io.WriteString(writer, request.URL.Path)
|
||||||
}))
|
}))
|
||||||
server.MaxConnections = 8
|
server.MaxConnections = 8
|
||||||
@@ -73,13 +79,37 @@ func TestTLSSoak(t *testing.T) {
|
|||||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||||
|
|
||||||
var requests, failures atomic.Uint64
|
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)
|
deadline := time.Now().Add(duration)
|
||||||
var workers sync.WaitGroup
|
var workers sync.WaitGroup
|
||||||
for worker := 0; worker < 128; worker++ {
|
for worker := 0; worker < 128; worker++ {
|
||||||
workers.Add(1)
|
workers.Add(1)
|
||||||
go func(worker int) {
|
go func(worker int) {
|
||||||
defer workers.Done()
|
defer workers.Done()
|
||||||
for time.Now().Before(deadline) {
|
for sequence := uint64(0); time.Now().Before(deadline); sequence++ {
|
||||||
path := "/health"
|
path := "/health"
|
||||||
method := http.MethodGet
|
method := http.MethodGet
|
||||||
var body io.Reader
|
var body io.Reader
|
||||||
@@ -95,24 +125,26 @@ func TestTLSSoak(t *testing.T) {
|
|||||||
request, err := http.NewRequestWithContext(requestContext, method, "https://"+listener.Addr().String()+path, body)
|
request, err := http.NewRequestWithContext(requestContext, method, "https://"+listener.Addr().String()+path, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancelRequest()
|
cancelRequest()
|
||||||
failures.Add(1)
|
recordFailure(err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
requestID := strconv.Itoa(worker) + ":" + strconv.FormatUint(sequence, 10)
|
||||||
|
request.Header.Set("X-Soak-Request-Id", requestID)
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancelRequest()
|
cancelRequest()
|
||||||
if time.Now().Before(deadline) {
|
recordFailure(err.Error())
|
||||||
failures.Add(1)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, readErr := io.Copy(io.Discard, response.Body)
|
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||||
response.Body.Close()
|
response.Body.Close()
|
||||||
cancelRequest()
|
cancelRequest()
|
||||||
if readErr != nil || response.StatusCode != http.StatusOK {
|
if readErr != nil {
|
||||||
if time.Now().Before(deadline) {
|
recordFailure(readErr.Error())
|
||||||
failures.Add(1)
|
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
|
continue
|
||||||
}
|
}
|
||||||
requests.Add(1)
|
requests.Add(1)
|
||||||
@@ -131,14 +163,37 @@ func TestTLSSoak(t *testing.T) {
|
|||||||
if err := <-done; err != nil {
|
if err := <-done; err != nil {
|
||||||
t.Fatalf("TLS soak serve: %v", err)
|
t.Fatalf("TLS soak serve: %v", err)
|
||||||
}
|
}
|
||||||
|
logSoakResources(t, started, "after", requests.Load(), failures.Load(), server.Stats())
|
||||||
if failures.Load() != 0 || requests.Load() == 0 {
|
if failures.Load() != 0 || requests.Load() == 0 {
|
||||||
t.Fatalf("TLS soak requests=%d failures=%d stats=%+v", requests.Load(), failures.Load(), server.Stats())
|
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 {
|
if stats := server.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 {
|
||||||
t.Fatalf("TLS soak did not reclaim resources: %+v", stats)
|
t.Fatalf("TLS soak protocol errors, unexpected rejection or unreclaimed resources: %+v", stats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
type repeatingReader struct {
|
||||||
remaining int
|
remaining int
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user