feat: add load generator connect scenario
Some checks are pending
ci / proto (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
ci / integration (push) Waiting to run

This commit is contained in:
youfak 2026-08-02 09:33:21 +08:00
parent b35092ccdd
commit d04b7413eb
8 changed files with 363 additions and 73 deletions

View File

@ -177,8 +177,8 @@ BASIC、EGRESS 与 TARGET 以有界轮转组共享上游并发上限。新启用
EGRESS 对成功响应提取纯文本 IP 或常见 JSON IP 字段并将其作为全局健康事实回传TARGET 事实 EGRESS 对成功响应提取纯文本 IP 或常见 JSON IP 字段并将其作为全局健康事实回传TARGET 事实
仅归并到对应的 `(routing_name, target_url)` Profile不改变 Proxy 全局健康。 仅归并到对应的 `(routing_name, target_url)` Profile不改变 Proxy 全局健康。
初版 HTTP 容量工具可按固定请求数或固定时长运行,并将 HTTPS 目标经 Gateway 的请求 `proxy-loadgen` 的 HTTP 场景可按固定请求数或固定时长运行,并将 HTTPS 目标经
交给标准 HTTP Transport 建立 CONNECT Gateway 的请求交给标准 HTTP Transport 建立 CONNECT
```powershell ```powershell
go run ./cmd/proxy-loadgen ` go run ./cmd/proxy-loadgen `
@ -187,6 +187,16 @@ go run ./cmd/proxy-loadgen `
-requests 10000 -concurrency 128 -timeout 10s -requests 10000 -concurrency 128 -timeout 10s
``` ```
CONNECT 长连接场景直接向 HTTP Gateway 发起隧道握手,并在成功后保持每条隧道指定
时长;`-timeout` 只限制 TCP 连接与 CONNECT 响应,`-hold` 控制建连后的保持时间:
```powershell
go run ./cmd/proxy-loadgen `
-scenario connect -target https://TARGET_URL/ `
-proxy http://GATEWAY_HOST:8080 `
-requests 1000 -concurrency 128 -timeout 5s -hold 30s
```
使用 `-duration 30s -rate 5000` 可运行限速场景;省略 `-rate` 时固定数量 worker 使用 `-duration 30s -rate 5000` 可运行限速场景;省略 `-rate` 时固定数量 worker
会饱和发送。`-method`、重复的 `-header``-body` 可组合用于 Distribution 的提取接口: 会饱和发送。`-method`、重复的 `-header``-body` 可组合用于 Distribution 的提取接口:
@ -198,9 +208,9 @@ go run ./cmd/proxy-loadgen `
-requests 1000 -concurrency 32 -timeout 10s -requests 1000 -concurrency 32 -timeout 10s
``` ```
命令输出 JSON 报告,包含成功/失败分类、固定内存的延迟分位上界、吞吐和 Go 命令输出 JSON 报告,包含成功/失败分类、`tunnelsEstablished`、固定内存的连接握手
运行时内存/GC 快照。它尚不包含长连接保持、Extract 的专用数据准备/结果校验, 延迟分位上界、吞吐和 Go 运行时内存/GC 快照。它尚不包含 Extract 的专用数据准备/
也不构成 100,000 QPS 证明。 结果校验,也不构成 100,000 QPS 证明。
## 关键配置与入口 ## 关键配置与入口

View File

@ -29,14 +29,16 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.
flags := flag.NewFlagSet("proxy-loadgen", flag.ContinueOnError) flags := flag.NewFlagSet("proxy-loadgen", flag.ContinueOnError)
flags.SetOutput(stderr) flags.SetOutput(stderr)
targetURL := flags.String("target", "", "HTTP or HTTPS target URL") targetURL := flags.String("target", "", "HTTP or HTTPS target URL")
proxyURL := flags.String("proxy", "", "optional HTTP or HTTPS forward proxy URL") proxyURL := flags.String("proxy", "", "optional HTTP or HTTPS forward proxy URL; connect requires HTTP")
scenario := flags.String("scenario", string(loadgen.ScenarioHTTP), "workload scenario: http or connect")
method := flags.String("method", http.MethodGet, "HTTP method") method := flags.String("method", http.MethodGet, "HTTP method")
body := flags.String("body", "", "UTF-8 request body") body := flags.String("body", "", "UTF-8 request body")
requests := flags.Int("requests", 0, "fixed request count; mutually exclusive with -duration") requests := flags.Int("requests", 0, "fixed request count; mutually exclusive with -duration")
duration := flags.Duration("duration", 0, "time-boxed workload duration") duration := flags.Duration("duration", 0, "time-boxed workload duration")
rate := flags.Int("rate", 0, "maximum request starts per second for -duration; zero saturates workers") rate := flags.Int("rate", 0, "maximum request starts per second for -duration; zero saturates workers")
concurrency := flags.Int("concurrency", 64, "maximum concurrent requests") concurrency := flags.Int("concurrency", 64, "maximum concurrent requests")
timeout := flags.Duration("timeout", 10*time.Second, "per-request timeout") timeout := flags.Duration("timeout", 10*time.Second, "HTTP request or CONNECT establishment timeout")
hold := flags.Duration("hold", 0, "CONNECT tunnel hold duration; required by -scenario connect")
var headers headerValues var headers headerValues
flags.Var(&headers, "header", "repeatable HTTP header in Name: Value form") flags.Var(&headers, "header", "repeatable HTTP header in Name: Value form")
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
@ -55,8 +57,9 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.
return 2 return 2
} }
report, err := run(ctx, loadgen.Options{ report, err := run(ctx, loadgen.Options{
TargetURL: *targetURL, ProxyURL: *proxyURL, Method: *method, Headers: parsedHeaders, RequestBody: []byte(*body), Scenario: loadgen.Scenario(*scenario), TargetURL: *targetURL, ProxyURL: *proxyURL, Method: *method, Headers: parsedHeaders, RequestBody: []byte(*body),
Requests: *requests, Duration: *duration, Rate: *rate, Concurrency: *concurrency, RequestTimeout: *timeout, Requests: *requests, Duration: *duration, Rate: *rate, Concurrency: *concurrency, RequestTimeout: *timeout,
TunnelHold: *hold,
}) })
if err != nil { if err != nil {
if errors.Is(err, loadgen.ErrInvalidOptions) { if errors.Is(err, loadgen.ErrInvalidOptions) {

View File

@ -38,3 +38,19 @@ func TestExecuteRejectsInvalidWorkload(t *testing.T) {
t.Fatalf("execute() = %d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) t.Fatalf("execute() = %d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
} }
} }
func TestExecutePassesCONNECTScenario(t *testing.T) {
var received loadgen.Options
var stdout, stderr bytes.Buffer
code := execute(context.Background(), []string{
"-scenario", "connect", "-target", "https://target.example/health", "-proxy", "http://gateway.example:8080",
"-requests", "2", "-concurrency", "1", "-timeout", "2s", "-hold", "30s",
}, func(_ context.Context, options loadgen.Options) (loadgen.Report, error) {
received = options
return loadgen.Report{Requests: 2, Completed: 2, Succeeded: 2, Duration: time.Second}, nil
}, &stdout, &stderr)
if code != 0 || received.Scenario != loadgen.ScenarioConnect || received.TunnelHold != 30*time.Second ||
received.ProxyURL != "http://gateway.example:8080" || stderr.Len() != 0 {
t.Fatalf("execute() = %d; options=%+v stderr=%q", code, received, stderr.String())
}
}

View File

@ -297,11 +297,12 @@ EGRESS 的出口身份响应解析支持固定上限的纯文本和常见 JSON I
Controller 即可生效。Redis 任务存储现已扩展 BASIC/EGRESS/TARGET 的独立有界索引;路由目标 Controller 即可生效。Redis 任务存储现已扩展 BASIC/EGRESS/TARGET 的独立有界索引;路由目标
Profile 在启用 Routing 与 Upstream 的组合上才进入调度。 Profile 在启用 Routing 与 Upstream 的组合上才进入调度。
补充进度2026-08-02已新增 `proxy-loadgen` HTTP 场景。固定请求数和固定时长两种 补充进度2026-08-02已新增 `proxy-loadgen` HTTP 与 CONNECT 长连接场景。固定请求数
模式均通过固定 worker 数与有界派发通道执行,可选 QPS 限速;报告使用固定大小延迟直方图, 和固定时长两种模式均通过固定 worker 数与有界派发通道执行,可选 QPS 限速;报告使用固定大小
输出状态分类、吞吐和 Go 内存/GC 快照。通用 `method/header/body` 参数可覆盖 Distribution 延迟直方图输出状态分类、CONNECT 建立数、吞吐和 Go 内存/GC 快照。CONNECT 以原始 TCP
提取 HTTP 请求CONNECT 长连接、Extract 的专用数据准备与结果校验、故障注入以及代表性集群 握手连接 HTTP Gateway建连成功后按 `hold` 保持,且不透明读取隧道内容。通用
报告仍未实现。 `method/header/body` 参数可覆盖 Distribution 提取 HTTP 请求Extract 的专用数据准备与结果
校验、故障注入以及代表性集群报告仍未实现。
## Task 12: Machine-readable Contracts ## Task 12: Machine-readable Contracts

View File

@ -100,7 +100,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
以下已有设计、接口或部署位置,但尚无端到端生产实现: 以下已有设计、接口或部署位置,但尚无端到端生产实现:
1. `proxy-loadgen` 已提供有界 HTTP 请求进程;`proxy-checker` 的 BASIC 任务进程已经完成, 1. `proxy-loadgen` 已提供有界 HTTP 与 HTTP Gateway CONNECT 长连接进程;`proxy-checker` 的 BASIC 任务进程已经完成,
`proxy-controller` 已完成 `proxy-controller` 已完成
Admin/Distribution/Metrics 与 PostgreSQL/Redis 启动装配,`proxy-gateway` 已完成 Admin/Distribution/Metrics 与 PostgreSQL/Redis 启动装配,`proxy-gateway` 已完成
HTTP/Metrics 与控制面 Session 装配,但 Provider 和业务指标链未闭环。 HTTP/Metrics 与控制面 Session 装配,但 Provider 和业务指标链未闭环。
@ -119,7 +119,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET 原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET
生产任务调度与 REMOVE 编排仍待实现。 生产任务调度与 REMOVE 编排仍待实现。
8. Admin/Distribution 细粒度授权和审计查询Distribution 分布式限流已完成。 8. Admin/Distribution 细粒度授权和审计查询Distribution 分布式限流已完成。
9. CONNECT 长连接/Extract 场景、真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。 9. Extract 专用场景、真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
10. 将 reject/wait/direct 接入 Distribution 运行链,补齐 Sequential 持久化恢复、跨实例 CAS 10. 将 reject/wait/direct 接入 Distribution 运行链,补齐 Sequential 持久化恢复、跨实例 CAS
和 disabled candidate 语义。 和 disabled candidate 语义。
11. 补齐 Proxy Capacity 动态降容契约、Reservation 全生命周期观测;短 TTL 11. 补齐 Proxy Capacity 动态降容契约、Reservation 全生命周期观测;短 TTL

View File

@ -7,7 +7,7 @@
| ID | 最终需求 | 来源 | 验证证据 | | ID | 最终需求 | 来源 | 验证证据 |
|---|---|---|---| |---|---|---|---|
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离Controller 已运行 Worker Register/Watch/ACK/Runtime/Outcome 与 Checker Observation gRPC并发布 Proxy/Gateway Routing/按引用去重凭据完整快照Checker 任务流已具备有界领取、租约栅栏和任务期凭据契约。Gateway 已将快照编译为同版本动态 View并由独立进程维护控制面会话。`proxy-checker` 与 Redis BASIC/EGRESS/TARGET 共享任务运行态、`proxy-loadgen` 有界 HTTP 场景均已实现 | | ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离Controller 已运行 Worker Register/Watch/ACK/Runtime/Outcome 与 Checker Observation gRPC并发布 Proxy/Gateway Routing/按引用去重凭据完整快照Checker 任务流已具备有界领取、租约栅栏和任务期凭据契约。Gateway 已将快照编译为同版本动态 View并由独立进程维护控制面会话。`proxy-checker` 与 Redis BASIC/EGRESS/TARGET 共享任务运行态、`proxy-loadgen` 有界 HTTP/CONNECT 长连接场景均已实现 |
| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | Gateway bootstrap 集成测试验证启动期控制面会话与快照就绪HTTP 请求只走本地 Snapshot/DispatchOutcome 仅写入有界非阻塞本地队列,代表性性能剖析待完成 | | ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | Gateway bootstrap 集成测试验证启动期控制面会话与快照就绪HTTP 请求只走本地 Snapshot/DispatchOutcome 仅写入有界非阻塞本地队列,代表性性能剖析待完成 |
| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | Controller 命令已装配 Distribution/Admin/Metrics 三个独立监听及联动停机Gateway 命令已装配代理与 Metrics 监听,运行时 mTLS 部署 Overlay 待完成 | | ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | Controller 命令已装配 Distribution/Admin/Metrics 三个独立监听及联动停机Gateway 命令已装配代理与 Metrics 监听,运行时 mTLS 部署 Overlay 待完成 |
| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Redis Leader、动态 Provider Supervisor 与 Bootstrap 生产装配已完成Admin disable/reload 驱动取消替换,多副本按权威 HMAC 指纹和 revision 栅栏收敛并拒绝旧配置换主Routing 切换到 Drain 的编排待完成 | | ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Redis Leader、动态 Provider Supervisor 与 Bootstrap 生产装配已完成Admin disable/reload 驱动取消替换,多副本按权威 HMAC 指纹和 revision 栅栏收敛并拒绝旧配置换主Routing 切换到 Drain 的编排待完成 |
@ -87,5 +87,5 @@
| OPS-001 | 配置校验后构建不可变快照并原子替换 | 8959-8999 | 100k 索引、版本/epoch 与并发 Apply/Acquire 测试 | | OPS-001 | 配置校验后构建不可变快照并原子替换 | 8959-8999 | 100k 索引、版本/epoch 与并发 Apply/Acquire 测试 |
| OPS-002 | 优雅停机停止新请求/Fetch等待现有流量后超时关闭 | 8981-9000 | Provider Run 收敛与 `Handler.Shutdown` HTTP 排空、Hijacked CONNECT 超时关闭测试 | | OPS-002 | 优雅停机停止新请求/Fetch等待现有流量后超时关闭 | 8981-9000 | Provider Run 收敛与 `Handler.Shutdown` HTTP 排空、Hijacked CONNECT 超时关闭测试 |
| OPS-003 | PostgreSQL 只保存管理修订、Upstream/Routing 状态、Admin 审计与 Outbox | 当前会话 | ADR-006、`adminstate` 公用契约和六表 Schema 边界测试;真实 PostgreSQL 契约待完成 | | OPS-003 | PostgreSQL 只保存管理修订、Upstream/Routing 状态、Admin 审计与 Outbox | 当前会话 | ADR-006、`adminstate` 公用契约和六表 Schema 边界测试;真实 PostgreSQL 契约待完成 |
| OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | Controller Prometheus/探针模块已实现且当前只暴露无业务标签的 Go/进程指标;低基数业务 Collector 与描述符测试待实现 | | OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | Controller Prometheus/探针模块已实现Checker Collector 仅暴露 `proxy_pool_checker_tasks_dispatched_total{level}``proxy_pool_checker_observations_total{level,result}`并以注册表测试固定标签集。Provider、提取和容量业务指标待实现 |
| TEST-001 | 覆盖对话中列出的 11 个关键并发与故障场景 | 9030-9082 | 测试清单Redis 活动池由 Memory/Redis 公用契约覆盖,跨进程故障场景仍按清单推进 | | TEST-001 | 覆盖对话中列出的 11 个关键并发与故障场景 | 9030-9082 | 测试清单Redis 活动池由 Memory/Redis 公用契约覆盖,跨进程故障场景仍按清单推进 |

View File

@ -3,11 +3,14 @@
package loadgen package loadgen
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/base64"
"errors" "errors"
"io" "io"
"math/bits" "math/bits"
"net"
"net/http" "net/http"
"net/url" "net/url"
"runtime" "runtime"
@ -21,9 +24,17 @@ var ErrInvalidOptions = errors.New("invalid load generator options")
const maximumResponseDrainBytes = 64 << 10 const maximumResponseDrainBytes = 64 << 10
type Scenario string
const (
ScenarioHTTP Scenario = "http"
ScenarioConnect Scenario = "connect"
)
// Options bounds one HTTP workload. Requests selects a fixed-size run; // Options bounds one HTTP workload. Requests selects a fixed-size run;
// otherwise Duration selects a time-boxed run and Rate caps its start rate. // otherwise Duration selects a time-boxed run and Rate caps its start rate.
type Options struct { type Options struct {
Scenario Scenario
TargetURL string TargetURL string
ProxyURL string ProxyURL string
Method string Method string
@ -34,22 +45,25 @@ type Options struct {
Rate int Rate int
Concurrency int Concurrency int
RequestTimeout time.Duration RequestTimeout time.Duration
TunnelHold time.Duration
} }
type Report struct { type Report struct {
StartedAt time.Time Scenario Scenario
Duration time.Duration StartedAt time.Time
Requests uint64 Duration time.Duration
Completed uint64 Requests uint64
Succeeded uint64 Completed uint64
Failed uint64 Succeeded uint64
Status4xx uint64 Failed uint64
Status5xx uint64 Status4xx uint64
TimeoutErrors uint64 Status5xx uint64
RequestErrors uint64 TimeoutErrors uint64
Throughput float64 RequestErrors uint64
Latency LatencyReport TunnelsEstablished uint64
Runtime RuntimeReport Throughput float64
Latency LatencyReport
Runtime RuntimeReport
} }
// LatencyReport uses logarithmic microsecond buckets. Percentiles are upper // LatencyReport uses logarithmic microsecond buckets. Percentiles are upper
@ -71,15 +85,16 @@ type RuntimeReport struct {
} }
type counters struct { type counters struct {
requests atomic.Uint64 requests atomic.Uint64
completed atomic.Uint64 completed atomic.Uint64
succeeded atomic.Uint64 succeeded atomic.Uint64
failed atomic.Uint64 failed atomic.Uint64
status4xx atomic.Uint64 status4xx atomic.Uint64
status5xx atomic.Uint64 status5xx atomic.Uint64
timeoutErrors atomic.Uint64 timeoutErrors atomic.Uint64
requestErrors atomic.Uint64 requestErrors atomic.Uint64
latency latencyHistogram tunnelsEstablished atomic.Uint64
latency latencyHistogram
} }
type latencyHistogram struct { type latencyHistogram struct {
@ -90,30 +105,44 @@ func Run(ctx context.Context, options Options) (Report, error) {
if ctx == nil { if ctx == nil {
return Report{}, ErrInvalidOptions return Report{}, ErrInvalidOptions
} }
client, normalized, err := newClient(options) normalized, err := normalizeOptions(options)
if err != nil { if err != nil {
return Report{}, err return Report{}, err
} }
defer client.CloseIdleConnections() stats := &counters{}
var execute func(context.Context)
if normalized.Scenario == ScenarioHTTP {
client, clientErr := newHTTPClient(normalized)
if clientErr != nil {
return Report{}, clientErr
}
defer client.CloseIdleConnections()
execute = func(requestCtx context.Context) { executeHTTP(requestCtx, client, normalized, stats) }
} else {
execute = func(requestCtx context.Context) { executeCONNECT(requestCtx, normalized, stats) }
}
started := time.Now() started := time.Now()
stats := &counters{}
if normalized.Requests > 0 { if normalized.Requests > 0 {
runFixed(ctx, client, normalized, stats) runFixed(ctx, normalized, execute)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return report(started, stats), err return report(started, normalized.Scenario, stats), err
} }
} else { } else {
runForDuration(ctx, client, normalized, stats) runForDuration(ctx, normalized, execute)
} }
return report(started, stats), nil return report(started, normalized.Scenario, stats), nil
} }
func newClient(options Options) (*http.Client, Options, error) { func normalizeOptions(options Options) (Options, error) {
normalized := options normalized := options
normalized.Scenario = Scenario(strings.ToLower(strings.TrimSpace(string(normalized.Scenario))))
normalized.Method = strings.ToUpper(strings.TrimSpace(normalized.Method)) normalized.Method = strings.ToUpper(strings.TrimSpace(normalized.Method))
normalized.Headers = normalized.Headers.Clone() normalized.Headers = normalized.Headers.Clone()
normalized.RequestBody = bytes.Clone(normalized.RequestBody) normalized.RequestBody = bytes.Clone(normalized.RequestBody)
if normalized.Scenario == "" {
normalized.Scenario = ScenarioHTTP
}
if normalized.Method == "" { if normalized.Method == "" {
normalized.Method = http.MethodGet normalized.Method = http.MethodGet
} }
@ -121,37 +150,71 @@ func newClient(options Options) (*http.Client, Options, error) {
normalized.Concurrency <= 0 || normalized.RequestTimeout <= 0 || normalized.Requests < 0 || normalized.Duration < 0 || normalized.Concurrency <= 0 || normalized.RequestTimeout <= 0 || normalized.Requests < 0 || normalized.Duration < 0 ||
normalized.Rate < 0 || (normalized.Requests == 0 && normalized.Duration <= 0) || normalized.Rate < 0 || (normalized.Requests == 0 && normalized.Duration <= 0) ||
(normalized.Requests > 0 && (normalized.Duration != 0 || normalized.Rate != 0)) { (normalized.Requests > 0 && (normalized.Duration != 0 || normalized.Rate != 0)) {
return nil, Options{}, ErrInvalidOptions return Options{}, ErrInvalidOptions
} }
target, err := url.Parse(normalized.TargetURL) target, err := url.Parse(normalized.TargetURL)
if err != nil || target.Scheme == "" || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") { if err != nil || target.Scheme == "" || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") {
return nil, Options{}, ErrInvalidOptions return Options{}, ErrInvalidOptions
}
if normalized.Scenario != ScenarioHTTP && normalized.Scenario != ScenarioConnect {
return Options{}, ErrInvalidOptions
}
if normalized.Scenario == ScenarioHTTP {
if normalized.TunnelHold != 0 {
return Options{}, ErrInvalidOptions
}
if _, err := parseProxyURL(normalized.ProxyURL, false); err != nil {
return Options{}, err
}
return normalized, nil
}
if normalized.ProxyURL == "" || normalized.TunnelHold <= 0 || len(normalized.RequestBody) != 0 || normalized.Method != http.MethodGet {
return Options{}, ErrInvalidOptions
}
if _, err := parseProxyURL(normalized.ProxyURL, true); err != nil {
return Options{}, err
}
return normalized, nil
}
func newHTTPClient(options Options) (*http.Client, error) {
parsed, err := parseProxyURL(options.ProxyURL, false)
if err != nil {
return nil, err
} }
var proxy func(*http.Request) (*url.URL, error) var proxy func(*http.Request) (*url.URL, error)
if normalized.ProxyURL != "" { if parsed != nil {
if strings.TrimSpace(normalized.ProxyURL) != normalized.ProxyURL {
return nil, Options{}, ErrInvalidOptions
}
parsed, err := url.Parse(normalized.ProxyURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, Options{}, ErrInvalidOptions
}
proxy = http.ProxyURL(parsed) proxy = http.ProxyURL(parsed)
} }
transport := &http.Transport{ transport := &http.Transport{
Proxy: proxy, Proxy: proxy,
ForceAttemptHTTP2: false, ForceAttemptHTTP2: false,
MaxConnsPerHost: normalized.Concurrency, MaxConnsPerHost: options.Concurrency,
MaxIdleConns: normalized.Concurrency, MaxIdleConns: options.Concurrency,
MaxIdleConnsPerHost: normalized.Concurrency, MaxIdleConnsPerHost: options.Concurrency,
IdleConnTimeout: 30 * time.Second, IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: normalized.RequestTimeout, TLSHandshakeTimeout: options.RequestTimeout,
ResponseHeaderTimeout: normalized.RequestTimeout, ResponseHeaderTimeout: options.RequestTimeout,
} }
return &http.Client{Transport: transport}, normalized, nil return &http.Client{Transport: transport}, nil
} }
func runFixed(ctx context.Context, client *http.Client, options Options, stats *counters) { func parseProxyURL(value string, plainHTTPOnly bool) (*url.URL, error) {
if value == "" {
return nil, nil
}
if strings.TrimSpace(value) != value {
return nil, ErrInvalidOptions
}
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") ||
(plainHTTPOnly && parsed.Scheme != "http") {
return nil, ErrInvalidOptions
}
return parsed, nil
}
func runFixed(ctx context.Context, options Options, execute func(context.Context)) {
jobs := make(chan struct{}, min(options.Concurrency, options.Requests)) jobs := make(chan struct{}, min(options.Concurrency, options.Requests))
var workers sync.WaitGroup var workers sync.WaitGroup
for range options.Concurrency { for range options.Concurrency {
@ -162,7 +225,7 @@ func runFixed(ctx context.Context, client *http.Client, options Options, stats *
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
executeOne(ctx, client, options, stats) execute(ctx)
} }
}() }()
} }
@ -179,7 +242,7 @@ func runFixed(ctx context.Context, client *http.Client, options Options, stats *
workers.Wait() workers.Wait()
} }
func runForDuration(ctx context.Context, client *http.Client, options Options, stats *counters) { func runForDuration(ctx context.Context, options Options, execute func(context.Context)) {
workloadContext, cancel := context.WithTimeout(ctx, options.Duration) workloadContext, cancel := context.WithTimeout(ctx, options.Duration)
defer cancel() defer cancel()
if options.Rate == 0 { if options.Rate == 0 {
@ -189,7 +252,7 @@ func runForDuration(ctx context.Context, client *http.Client, options Options, s
go func() { go func() {
defer workers.Done() defer workers.Done()
for workloadContext.Err() == nil { for workloadContext.Err() == nil {
executeOne(workloadContext, client, options, stats) execute(workloadContext)
} }
}() }()
} }
@ -208,7 +271,7 @@ func runForDuration(ctx context.Context, client *http.Client, options Options, s
case <-workloadContext.Done(): case <-workloadContext.Done():
return return
case <-jobs: case <-jobs:
executeOne(workloadContext, client, options, stats) execute(workloadContext)
} }
} }
}() }()
@ -244,7 +307,7 @@ func runForDuration(ctx context.Context, client *http.Client, options Options, s
} }
} }
func executeOne(ctx context.Context, client *http.Client, options Options, stats *counters) { func executeHTTP(ctx context.Context, client *http.Client, options Options, stats *counters) {
stats.requests.Add(1) stats.requests.Add(1)
started := time.Now() started := time.Now()
requestContext, cancel := context.WithTimeout(ctx, options.RequestTimeout) requestContext, cancel := context.WithTimeout(ctx, options.RequestTimeout)
@ -293,17 +356,136 @@ func executeOne(ctx context.Context, client *http.Client, options Options, stats
} }
} }
func report(started time.Time, stats *counters) Report { func executeCONNECT(ctx context.Context, options Options, stats *counters) {
stats.requests.Add(1)
started := time.Now()
proxy, err := parseProxyURL(options.ProxyURL, true)
if err != nil {
recordRequestError(started, stats, false)
return
}
target, err := url.Parse(options.TargetURL)
if err != nil {
recordRequestError(started, stats, false)
return
}
authority := target.Host
if target.Port() == "" {
if target.Scheme == "https" {
authority = net.JoinHostPort(target.Hostname(), "443")
} else {
authority = net.JoinHostPort(target.Hostname(), "80")
}
}
requestContext, cancel := context.WithTimeout(ctx, options.RequestTimeout)
defer cancel()
connection, err := (&net.Dialer{}).DialContext(requestContext, "tcp", proxy.Host)
if err != nil {
recordRequestError(started, stats, errors.Is(requestContext.Err(), context.DeadlineExceeded))
return
}
defer connection.Close()
if err := connection.SetDeadline(time.Now().Add(options.RequestTimeout)); err != nil {
recordRequestError(started, stats, false)
return
}
if err := writeCONNECTRequest(connection, authority, options.Headers, proxy.User); err != nil {
recordRequestError(started, stats, errors.Is(requestContext.Err(), context.DeadlineExceeded))
return
}
response, err := http.ReadResponse(bufio.NewReader(connection), &http.Request{Method: http.MethodConnect})
latency := time.Since(started)
stats.latency.Record(latency)
if err != nil {
stats.failed.Add(1)
stats.completed.Add(1)
if errors.Is(requestContext.Err(), context.DeadlineExceeded) {
stats.timeoutErrors.Add(1)
} else {
stats.requestErrors.Add(1)
}
return
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
stats.failed.Add(1)
stats.completed.Add(1)
switch {
case response.StatusCode >= http.StatusInternalServerError:
stats.status5xx.Add(1)
case response.StatusCode >= http.StatusBadRequest:
stats.status4xx.Add(1)
default:
stats.requestErrors.Add(1)
}
return
}
if err := connection.SetDeadline(time.Time{}); err != nil {
stats.failed.Add(1)
stats.requestErrors.Add(1)
stats.completed.Add(1)
return
}
stats.succeeded.Add(1)
stats.tunnelsEstablished.Add(1)
waitForTunnelHold(ctx, options.TunnelHold)
stats.completed.Add(1)
}
func writeCONNECTRequest(connection net.Conn, authority string, headers http.Header, user *url.Userinfo) error {
if connection == nil || authority == "" {
return ErrInvalidOptions
}
requestHeaders := headers.Clone()
requestHeaders.Del("Host")
if user != nil && requestHeaders.Get("Proxy-Authorization") == "" {
password, _ := user.Password()
encoded := base64.StdEncoding.EncodeToString([]byte(user.Username() + ":" + password))
requestHeaders.Set("Proxy-Authorization", "Basic "+encoded)
}
var payload bytes.Buffer
_, _ = payload.WriteString("CONNECT " + authority + " HTTP/1.1\r\nHost: " + authority + "\r\n")
if err := requestHeaders.Write(&payload); err != nil {
return err
}
_, _ = payload.WriteString("\r\n")
_, err := io.Copy(connection, &payload)
return err
}
func waitForTunnelHold(ctx context.Context, hold time.Duration) {
timer := time.NewTimer(hold)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
}
func recordRequestError(started time.Time, stats *counters, timeout bool) {
stats.latency.Record(time.Since(started))
stats.failed.Add(1)
stats.completed.Add(1)
if timeout {
stats.timeoutErrors.Add(1)
return
}
stats.requestErrors.Add(1)
}
func report(started time.Time, scenario Scenario, stats *counters) Report {
duration := time.Since(started) duration := time.Since(started)
completed := stats.completed.Load() completed := stats.completed.Load()
var memory runtime.MemStats var memory runtime.MemStats
runtime.ReadMemStats(&memory) runtime.ReadMemStats(&memory)
result := Report{ result := Report{
StartedAt: started.UTC(), Duration: duration, Requests: stats.requests.Load(), Completed: completed, Scenario: scenario, StartedAt: started.UTC(), Duration: duration, Requests: stats.requests.Load(), Completed: completed,
Succeeded: stats.succeeded.Load(), Failed: stats.failed.Load(), Status4xx: stats.status4xx.Load(), Succeeded: stats.succeeded.Load(), Failed: stats.failed.Load(), Status4xx: stats.status4xx.Load(),
Status5xx: stats.status5xx.Load(), TimeoutErrors: stats.timeoutErrors.Load(), RequestErrors: stats.requestErrors.Load(), Status5xx: stats.status5xx.Load(), TimeoutErrors: stats.timeoutErrors.Load(), RequestErrors: stats.requestErrors.Load(),
Latency: stats.latency.Report(completed), TunnelsEstablished: stats.tunnelsEstablished.Load(),
Runtime: RuntimeReport{NumCPU: runtime.NumCPU(), Goroutines: runtime.NumGoroutine(), AllocBytes: memory.Alloc, SysBytes: memory.Sys, GCCount: memory.NumGC}, Latency: stats.latency.Report(completed),
Runtime: RuntimeReport{NumCPU: runtime.NumCPU(), Goroutines: runtime.NumGoroutine(), AllocBytes: memory.Alloc, SysBytes: memory.Sys, GCCount: memory.NumGC},
} }
if duration > 0 { if duration > 0 {
result.Throughput = float64(completed) / duration.Seconds() result.Throughput = float64(completed) / duration.Seconds()

View File

@ -1,9 +1,12 @@
package loadgen package loadgen
import ( import (
"bufio"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic" "sync/atomic"
@ -67,6 +70,81 @@ func TestRunTimeBoxedRateIsBounded(t *testing.T) {
} }
} }
func TestRunExecutesBoundedCONNECTTunnelWorkload(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen(): %v", err)
}
defer listener.Close()
var tunnels atomic.Int64
serveDone := make(chan struct{})
go func() {
defer close(serveDone)
for range 3 {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
go func(connection net.Conn) {
defer connection.Close()
request, readErr := http.ReadRequest(bufio.NewReader(connection))
if readErr != nil || request.Method != http.MethodConnect || request.Host != "target.example:443" {
t.Errorf("CONNECT request = %+v, error = %v", request, readErr)
return
}
tunnels.Add(1)
_, _ = fmt.Fprint(connection, "HTTP/1.1 200 Connection Established\r\n\r\n")
_, _ = io.Copy(io.Discard, connection)
}(connection)
}
}()
report, err := Run(context.Background(), Options{
Scenario: ScenarioConnect, TargetURL: "https://target.example/health", ProxyURL: "http://" + listener.Addr().String(),
Requests: 3, Concurrency: 2, RequestTimeout: time.Second, TunnelHold: 10 * time.Millisecond,
})
if err != nil || report.Requests != 3 || report.Completed != 3 || report.Succeeded != 3 || report.Failed != 0 ||
report.TunnelsEstablished != 3 || tunnels.Load() != 3 || report.Latency.P50UpperBound <= 0 {
t.Fatalf("Run() = (%+v, %v); CONNECT requests=%d", report, err, tunnels.Load())
}
<-serveDone
}
func TestRunClassifiesCONNECTProxyRejection(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen(): %v", err)
}
defer listener.Close()
go func() {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer connection.Close()
_, _ = http.ReadRequest(bufio.NewReader(connection))
_, _ = fmt.Fprint(connection, "HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n")
}()
report, err := Run(context.Background(), Options{
Scenario: ScenarioConnect, TargetURL: "https://target.example/health", ProxyURL: "http://" + listener.Addr().String(),
Requests: 1, Concurrency: 1, RequestTimeout: time.Second, TunnelHold: time.Millisecond,
})
if err != nil || report.Succeeded != 0 || report.Failed != 1 || report.Status4xx != 1 || report.TunnelsEstablished != 0 {
t.Fatalf("Run() = (%+v, %v)", report, err)
}
}
func TestRunRejectsCONNECTWithoutProxyOrHold(t *testing.T) {
_, err := Run(context.Background(), Options{
Scenario: ScenarioConnect, TargetURL: "https://target.example/health", Requests: 1, Concurrency: 1, RequestTimeout: time.Second,
})
if !errors.Is(err, ErrInvalidOptions) {
t.Fatalf("Run(connect without proxy or hold) error = %v, want ErrInvalidOptions", err)
}
}
func TestRunRejectsUnboundedWorkload(t *testing.T) { func TestRunRejectsUnboundedWorkload(t *testing.T) {
_, err := Run(context.Background(), Options{TargetURL: "http://127.0.0.1:8080", Concurrency: 1}) _, err := Run(context.Background(), Options{TargetURL: "http://127.0.0.1:8080", Concurrency: 1})
if !errors.Is(err, ErrInvalidOptions) { if !errors.Is(err, ErrInvalidOptions) {