Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions .github/workflows/proxy-throughput.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
name: Proxy Throughput Tests

on:
pull_request:
branches: ["master"]
paths:
- "clash-lib/src/proxy/**"
- "clash-lib/src/app/**"
- ".github/workflows/proxy-throughput.yml"
workflow_dispatch:

permissions:
contents: read
pull-requests: write

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
RUST_LOG: "clash_lib=info"
RUST_TOOLCHAIN: "stable"
THROUGHPUT_RESULTS_FILE: ${{ github.workspace }}/throughput-results.json

jobs:
throughput:
name: SS E2E Throughput
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}

- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: throughput-${{ runner.os }}

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake libclang-dev protobuf-compiler

# Build the binary that the tests will spawn as a subprocess.
# Do NOT use --all-features here: the `telemetry` feature enables
# console_subscriber and OpenTelemetry threads that deadlock with the
# main thread during crypto initialization when multiple instances start
# concurrently. Default features already include shadowsocks, tuic,
# wireguard, and everything else needed for these tests.
- name: Build clash-rs binary (release)
run: cargo build --release --bin clash-rs

- name: Print environment info
run: |
echo "=== Runner environment ==="
echo "OS: $(uname -a)"
echo "CPUs: $(nproc) logical, $(lscpu | awk '/^Model name/{sub(/.*: */,""); print}')"
echo "Memory: $(free -h | awk '/^Mem/{print $2, "total,", $7, "available"}')"
echo "Disk: $(df -h / | awk 'NR==2{print $4, "free on /"}')"
echo "=== Docker ==="
docker version --format 'Client: {{.Client.Version}} Server: {{.Server.Version}}'
echo "=== Rust ==="
rustc --version
cargo --version
echo "=== Results file will be written to ==="
echo " ${{ env.THROUGHPUT_RESULTS_FILE }}"

# Pull docker images in advance so test startup is faster
- name: Pull docker images
run: |
docker pull teddysun/shadowsocks-rust:alpine-1.22.0
docker pull gists/simple-obfs:latest
docker pull ghcr.io/ihciah/shadow-tls:latest

# Download once so parallel clash-rs instances don't race to fetch it
- name: Download Country.mmdb
run: |
mmdb_path="clash-bin/tests/data/config/Country.mmdb"
curl -fL https://github.com/Loyalsoldier/geoip/releases/latest/download/Country.mmdb \
-o "$mmdb_path"

- name: Run throughput tests
env:
# Absolute path: cargo test -p clash-lib sets the test binary's CWD
# to clash-lib/, so a relative path would land there instead of the
# workspace root. The workflow-level env sets an absolute path.
THROUGHPUT_RESULTS_FILE: ${{ env.THROUGHPUT_RESULTS_FILE }}
RUST_LOG: ${{ env.RUST_LOG }}
CLASH_RS_CI: "true"
run: |
set -o pipefail
echo "Results will be written to: ${THROUGHPUT_RESULTS_FILE}"
RUSTFLAGS="--cfg docker_test --cfg throughput_test" \
cargo test -p clash-lib --release --all-features \
e2e_throughput \
-- --test-threads=8 --nocapture 2>&1 | tee throughput-test.log

- name: Format results as Markdown
if: always()
run: |
results_file="${{ env.THROUGHPUT_RESULTS_FILE }}"
if [ -f "$results_file" ]; then
python3 bench/format_throughput.py \
"$results_file" \
--output throughput-comment.md
# Also write to GitHub step summary
cat throughput-comment.md >> $GITHUB_STEP_SUMMARY
else
echo "## 📊 Proxy Throughput Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ No results file found — tests may have failed before producing output." >> $GITHUB_STEP_SUMMARY
echo "## 📊 Proxy Throughput Results" > throughput-comment.md
echo "" >> throughput-comment.md
echo "⚠️ No results file found — tests may have failed before producing output." >> throughput-comment.md
fi

- name: Upload result artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: throughput-results
path: |
${{ env.THROUGHPUT_RESULTS_FILE }}
throughput-comment.md
throughput-test.log
retention-days: 90

- name: Post PR comment
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');

let body;
if (fs.existsSync('throughput-comment.md')) {
body = fs.readFileSync('throughput-comment.md', 'utf8');
} else {
body = '## 📊 Proxy Throughput Results\n\n⚠️ No results produced — check workflow logs.';
}

// Append a link to the full log artifact
body += '\n\n<details><summary>Full test log</summary>\n\n';
body += 'Download the `throughput-results` artifact for the full log.\n\n</details>';

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const existing = comments.find(c =>
c.user.type === 'Bot' &&
c.body.includes('Proxy Throughput Results')
);

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ panic = "abort"

[workspace.lints.rust]
warnings = "deny"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(docker_test)', 'cfg(throughput_test)'] }

[workspace.lints.clippy]
redundant_clone = "deny"
Expand Down
69 changes: 69 additions & 0 deletions bench/format_throughput.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Format throughput test JSON-lines results as a Markdown table.

Usage:
python3 bench/format_throughput.py results.json [--output comment.md]

Each line of results.json must be a JSON object with:
label – human-readable test name
upload_mbps – upload throughput in Mbps
download_mbps – download throughput in Mbps
total_bytes – payload size in bytes
"""

import argparse
import json
import sys


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("results", help="JSON-lines result file")
parser.add_argument("--output", "-o", help="Write markdown to this file (default: stdout)")
args = parser.parse_args()

rows = []
with open(args.results) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError as e:
print(f"Warning: skipping invalid JSON line: {e}", file=sys.stderr)

if not rows:
md = "## 📊 Proxy Throughput Results\n\n_No results recorded._\n"
else:
rows.sort(key=lambda r: r.get("label", ""))
lines = [
"## 📊 Proxy Throughput Results",
"",
"| Transport | Payload | Upload (Mbps) | Download (Mbps) |",
"|-----------|---------|:-------------:|:---------------:|",
]
for r in rows:
label = r.get("label", "?")
payload_mb = r.get("total_bytes", 0) // (1024 * 1024)
upload = r.get("upload_mbps", 0.0)
download = r.get("download_mbps", 0.0)
lines.append(f"| `{label}` | {payload_mb} MB | {upload:.1f} | {download:.1f} |")

lines += [
"",
f"_Tests ran {len(rows)} variant(s) in parallel; each direction transfers the full payload._",
"",
]
md = "\n".join(lines)

if args.output:
with open(args.output, "w") as f:
f.write(md)
print(f"Written to {args.output}")
else:
print(md)


if __name__ == "__main__":
main()
13 changes: 8 additions & 5 deletions clash-lib/src/proxy/converters/shadowsocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,6 @@ impl TryFrom<HashMap<String, serde_yaml::Value>> for V2RayOBFSOption {
.get("mode")
.and_then(|x| x.as_str())
.ok_or(Error::InvalidConfig("obfs mode is required".to_owned()))?;
let port = value
.get("port")
.and_then(|x| x.as_u64())
.ok_or(Error::InvalidConfig("obfs port is required".to_owned()))?
as u16;

if mode != "websocket" {
return Err(Error::InvalidConfig(format!("invalid obfs mode: {mode}")));
Expand All @@ -154,6 +149,14 @@ impl TryFrom<HashMap<String, serde_yaml::Value>> for V2RayOBFSOption {
let path = value.get("path").and_then(|x| x.as_str()).unwrap_or("");
let mux = value.get("mux").and_then(|x| x.as_bool()).unwrap_or(false);
let tls = value.get("tls").and_then(|x| x.as_bool()).unwrap_or(false);
// port is optional in plugin-opts; real Clash configs omit it and rely
// on the main proxy port for the actual TCP connection. The value here
// is only used for the WebSocket HTTP Upgrade Host header, so default
// to the standard port for the chosen scheme.
let port = value
.get("port")
.and_then(|x| x.as_u64())
.unwrap_or(if tls { 443 } else { 80 }) as u16;
let skip_cert_verify = value
.get("skip-cert-verify")
.and_then(|x| x.as_bool())
Expand Down
Loading
Loading