Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
145 changes: 145 additions & 0 deletions .github/workflows/proxy-throughput.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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: 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
- name: Build clash-rs binary (release)
run: cargo build --release --bin clash-rs --all-features

# 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

- name: Run throughput tests
env:
THROUGHPUT_RESULTS_FILE: ${{ env.THROUGHPUT_RESULTS_FILE }}
RUST_LOG: ${{ env.RUST_LOG }}
CLASH_RS_CI: "true"
run: |
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
continue-on-error: true

- name: Format results as Markdown
if: always()
run: |
if [ -f "${{ env.THROUGHPUT_RESULTS_FILE }}" ]; then
python3 bench/format_throughput.py \
"${{ env.THROUGHPUT_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: |
throughput-results.json
throughput-comment.md
throughput-test.log
retention-days: 90

- name: Post PR comment
if: 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()
Loading
Loading