Skip to content

feat: e2e throughput tests for SS outbound + transport Poll::Pending bug fixes#1332

Merged
ibigbug merged 12 commits into
masterfrom
feat/ss-e2e-throughput-tests
Apr 26, 2026
Merged

feat: e2e throughput tests for SS outbound + transport Poll::Pending bug fixes#1332
ibigbug merged 12 commits into
masterfrom
feat/ss-e2e-throughput-tests

Conversation

@ibigbug
Copy link
Copy Markdown
Member

@ibigbug ibigbug commented Apr 25, 2026

Summary

Bug fixes (spotted by reviewing #1330 pattern)

  • simple_obfs/http.rs: Fixed two Poll::Pending state-loss bugs:

    • poll_write: HTTP upgrade header was rebuilt on every call — lost if inner write returned Pending
    • poll_read: Partial HTTP response header accumulated in a stack-local buffer — lost on Pending, causing parse errors on fragmented responses
  • vmess/vmess_impl/http.rs: Fixed four bugs in the HTTP transport:

    • poll_write: Same header rebuild issue as above
    • poll_read: Local header_buf lost on Pending; BytesMut with len=0 → 0-capacity ReadBuf (inner stream could never deliver bytes); httparse::Request used instead of httparse::Response; zero header slots allocated

E2E throughput test harness

Full-stack tests: test client → SOCKS5 inbound → dispatcher → SS outbound → docker proxy container → local echo server → back. 32 MB payload each direction.

5 parallel tests (no #[serial_test::serial]):

Test Transport
e2e_throughput_ss_plain aes-256-gcm
e2e_throughput_ss_obfs_http simple-obfs HTTP
e2e_throughput_ss_obfs_tls simple-obfs TLS
e2e_throughput_ss_v2ray_plugin WebSocket + TLS
e2e_throughput_ss_shadowtls shadow-tls v3

Ports allocated via a global AtomicU16 counter (starting at 52000) — no port collisions possible.

CI pipeline (.github/workflows/proxy-throughput.yml)

  • Gated by --cfg docker_test --cfg throughput_test (separate from regular docker tests)
  • Triggers on PRs touching clash-lib/src/proxy/** or manual dispatch
  • Results written as JSON lines to THROUGHPUT_RESULTS_FILE, formatted as a Markdown table by bench/format_throughput.py
  • Posts results as a PR comment (upsert) + writes to $GITHUB_STEP_SUMMARY
  • Uploads raw JSON + log as a 90-day artifact

Run locally

cargo build --release -p clash-rs
THROUGHPUT_RESULTS_FILE=results.json RUSTFLAGS="--cfg docker_test --cfg throughput_test" cargo test -p clash-lib --release --all-features e2e_throughput -- --nocapture
python3 bench/format_throughput.py results.json

Summary by CodeRabbit

  • Tests

    • Added end-to-end throughput tests covering multiple proxy variants and transports
    • Added CI workflow ("Proxy Throughput Tests") to run benchmarks on PRs, upload artifacts, and post formatted summaries
  • Bug Fixes

    • Improved HTTP obfuscation to correctly handle partial reads/writes, draining, flushing, and shutdown
  • Chores

    • Added test utilities for port allocation, binary discovery, result persistence, orchestration, and container networking builder options
  • New Features

    • Added CLI to format raw throughput results into Markdown summaries for artifacts and PR comments

- Fix Poll::Pending state-loss in simple_obfs/http.rs (write + read)
- Fix Poll::Pending state-loss in vmess/vmess_impl/http.rs (write + read,
  0-capacity ReadBuf, wrong httparse::Request parser, zero header slots)
- Add e2e throughput test harness in docker_utils (alloc_port, ThroughputResult,
  find_clash_rs_binary, socks5_connect, clash_process_e2e_throughput)
- Add 5 parallel SS e2e tests covering plain, obfs-http, obfs-tls,
  v2ray-plugin WS+TLS, shadow-tls v3; gated by --cfg throughput_test
- Write JSON-lines results to THROUGHPUT_RESULTS_FILE for CI collection
- Add bench/format_throughput.py to render results as a Markdown table
- Add .github/workflows/proxy-throughput.yml pipeline: builds release
  binary, runs tests with --test-threads=8, posts results as PR comment
  and GitHub Step Summary, uploads artifacts for 90 days

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a CI workflow and formatter, Docker-gated throughput e2e scaffolding and a Shadowsocks throughput test module, and refactors HTTP obfuscation streams to use internal buffering with correct partial-read/partial-write handling.

Changes

Cohort / File(s) Summary
CI Workflow & Formatter
\.github/workflows/proxy-throughput.yml, bench/format_throughput.py
New GitHub Actions workflow to build release binaries, pre-pull Docker images, run throughput e2e tests (continue-on-error), upload artifacts, and post/update PR comments. Adds a Python CLI to parse JSONL results and emit a Markdown report.
Throughput Test Utilities
clash-lib/src/proxy/utils/test_utils/docker_utils/...
Adds global atomic port allocator, ThroughputResult, binary discovery for clash-rs, Docker network/readiness helpers, JSONL persistence, and clash_process_e2e_throughput to spawn clash-rs, run echo server, drive traffic, and measure Mbps (gated by docker_test & throughput_test).
Docker Runner API
clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs
Adds DockerTestRunnerBuilder::no_port() to build containers without host port bindings for internal-network-only scenarios.
Shadowsocks E2E Throughput Tests
clash-lib/src/proxy/shadowsocks/outbound/mod.rs
Introduces Docker-gated e2e throughput test module that boots SS/plugin server variants, dynamically allocates ports, generates Clash YAML configs, and runs full-stack throughput measurements by spawning clash-rs.
HTTP Obfuscation Stream Buffering
clash-lib/src/proxy/transport/simple_obfs/http.rs, clash-lib/src/proxy/vmess/vmess_impl/http.rs
Refactors HTTP obfuscation implementations to stage first-request headers+payload into write buffers, support incremental draining on writes, accumulate response bytes until headers parsed, handle partial reads/writes correctly, and return an error on premature EOF during header read.
Lint Configuration
Cargo.toml
Updates lint config to permit cfg(docker_test) and cfg(throughput_test) usages to avoid unexpected-cfg failures for test-only code.

Sequence Diagram

sequenceDiagram
    participant GHA as GitHub Actions
    participant Runner as Runner (build/test)
    participant Docker as Docker (images/containers)
    participant ClashRS as clash-rs subprocess
    participant SOCKS5 as SOCKS5 inbound
    participant Echo as Echo server
    participant Outbound as SS/Plugin outbound
    participant Metrics as Throughput collector
    participant Formatter as bench/format_throughput.py
    participant PR as Pull Request

    GHA->>Runner: Trigger workflow (PR / workflow_dispatch)
    Runner->>Runner: Install toolchain & deps, build clash-rs (release)
    Runner->>Docker: Pull required images
    Runner->>Docker: Start SS/plugin containers (allocated ports)
    Runner->>ClashRS: Spawn clash-rs with generated YAML
    ClashRS->>SOCKS5: Accept connection
    Runner->>Echo: Start echo server
    SOCKS5->>Outbound: Dispatch to outbound plugin/server
    Outbound->>Echo: Forward traffic
    Echo->>Outbound: Reflect payload
    Outbound->>Metrics: Return data (measure timings)
    Metrics->>Runner: Write JSONL results
    Runner->>Formatter: Run formatter -> produce throughput-comment.md
    Runner->>PR: Post or update PR comment with formatted results
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested Reviewers

  • Itsusinn

Poem

🐰 I buffered bytes and hopped through night,

Docker drums and payloads taking flight,
Partial writes now drain in order true,
Mbps parade — a carrot-shaped view,
I scrub the logs and bring the numbers bright.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main contributions: end-to-end throughput tests for Shadowsocks and critical Poll::Pending bug fixes in transport layers.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ss-e2e-throughput-tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
.github/workflows/proxy-throughput.yml (1)

126-129: Bot comment detection may be fragile.

The detection relies on c.user.type === 'Bot' and body containing "Proxy Throughput Results". Consider also checking c.user.login includes "github-actions" for more robust detection.

Suggested improvement
 const existing = comments.find(c =>
-  c.user.type === 'Bot' &&
+  c.user?.type === 'Bot' &&
+  c.user?.login?.includes('github-actions') &&
   c.body.includes('Proxy Throughput Results')
 );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/proxy-throughput.yml around lines 126 - 129, The bot
comment detection in the comments.find predicate is fragile because it only
checks c.user.type === 'Bot' and body text; update the predicate used when
assigning existing to also verify the comment author's login contains
"github-actions" (e.g., check c.user.login &&
c.user.login.includes('github-actions')) in addition to the existing
body.includes('Proxy Throughput Results') check so the search reliably
identifies the GitHub Actions bot comment; modify the predicate inside the
comments.find call (where existing is declared) to include this additional login
check.
bench/format_throughput.py (1)

8-11: Minor: Replace en-dash (–) with hyphen-minus (-) in docstring.

The docstring uses Unicode en-dash characters which Ruff flags as ambiguous. While this doesn't affect functionality, using standard ASCII hyphens improves consistency.

Suggested fix
-    label         – human-readable test name
-    upload_mbps   – upload throughput in Mbps
-    download_mbps – download throughput in Mbps
-    total_bytes   – payload size in bytes
+    label         - human-readable test name
+    upload_mbps   - upload throughput in Mbps
+    download_mbps - download throughput in Mbps
+    total_bytes   - payload size in bytes
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bench/format_throughput.py` around lines 8 - 11, The docstring lines defining
"label", "upload_mbps", "download_mbps", and "total_bytes" use Unicode en-dashes
(–); replace each en-dash with the ASCII hyphen-minus (-) so the descriptions
read "label - human-readable test name", "upload_mbps - upload throughput in
Mbps", "download_mbps - download throughput in Mbps", and "total_bytes - payload
size in bytes" to remove the ambiguous character flagged by linters.
clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs (1)

67-82: Consider preferring release build for throughput tests.

The function prefers debug build "for faster iteration", but throughput tests are run in release mode by the CI workflow. For consistency, consider preferring release build first in throughput test contexts, or making the preference configurable.

However, this is a minor concern since the workflow explicitly builds --release before running tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs` around lines 67 -
82, The current find_clash_rs_binary function prefers the debug binary
(variables debug/release) which can cause inconsistency with CI throughput
tests; update find_clash_rs_binary in
clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs to prefer the release
build first (check release.exists() before debug.exists()) or make the
preference configurable via an environment variable (e.g.,
CLASHRS_PREFER_RELEASE) so the function (using config_helper::root_dir(), and
variables debug/release) will select release when requested.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In @.github/workflows/proxy-throughput.yml:
- Around line 126-129: The bot comment detection in the comments.find predicate
is fragile because it only checks c.user.type === 'Bot' and body text; update
the predicate used when assigning existing to also verify the comment author's
login contains "github-actions" (e.g., check c.user.login &&
c.user.login.includes('github-actions')) in addition to the existing
body.includes('Proxy Throughput Results') check so the search reliably
identifies the GitHub Actions bot comment; modify the predicate inside the
comments.find call (where existing is declared) to include this additional login
check.

In `@bench/format_throughput.py`:
- Around line 8-11: The docstring lines defining "label", "upload_mbps",
"download_mbps", and "total_bytes" use Unicode en-dashes (–); replace each
en-dash with the ASCII hyphen-minus (-) so the descriptions read "label -
human-readable test name", "upload_mbps - upload throughput in Mbps",
"download_mbps - download throughput in Mbps", and "total_bytes - payload size
in bytes" to remove the ambiguous character flagged by linters.

In `@clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs`:
- Around line 67-82: The current find_clash_rs_binary function prefers the debug
binary (variables debug/release) which can cause inconsistency with CI
throughput tests; update find_clash_rs_binary in
clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs to prefer the release
build first (check release.exists() before debug.exists()) or make the
preference configurable via an environment variable (e.g.,
CLASHRS_PREFER_RELEASE) so the function (using config_helper::root_dir(), and
variables debug/release) will select release when requested.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cbdd5214-ef28-4b5d-acce-11260eacd168

📥 Commits

Reviewing files that changed from the base of the PR and between db28b09 and 95d38b4.

📒 Files selected for processing (6)
  • .github/workflows/proxy-throughput.yml
  • bench/format_throughput.py
  • clash-lib/src/proxy/shadowsocks/outbound/mod.rs
  • clash-lib/src/proxy/transport/simple_obfs/http.rs
  • clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs
  • clash-lib/src/proxy/vmess/vmess_impl/http.rs

ibigbug and others added 3 commits April 24, 2026 23:37
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Gate NEXT_PORT/alloc_port/ThroughputResult/find_clash_rs_binary under
  #[cfg(throughput_test)] to avoid dead_code errors in normal builds
- Wrap the full upload+download in the destination retry loop so that
  EPIPE/broken-pipe during data transfer tries the next destination
  (previously only socks5_connect errors triggered a retry)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ut tests

- Add .port(obfs_port) to get_obfs_runner() to avoid defaulting to the
  hardcoded PORT=10002, which caused conflicts when two obfs tests ran
  in parallel
- Add .port(stls_port) to get_shadowtls_runner() for the same reason
- Fix container cleanup leak in two-container tests (obfs-http,
  obfs-tls, shadowtls): if the second container fails to start, the
  first container was dropped without async cleanup; use an explicit
  match + c1.cleanup().await.ok() to guarantee cleanup on that path

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
clash-lib/src/proxy/shadowsocks/outbound/mod.rs (2)

578-578: Use SHADOW_TLS_PASSWORD in generated plugin YAML to prevent drift.

Line 924 hardcodes "password" while a shared constant already exists at Line 578.

Proposed fix
-        let plugin_yaml = r#"    plugin: shadow-tls
+        let plugin_yaml = format!(r#"    plugin: shadow-tls
     plugin-opts:
       host: www.feishu.cn
-      password: password
-      version: 3"#;
-        let config = ss_base_config(&server, stls_port, socks_port, plugin_yaml);
+      password: {}
+      version: 3"#, SHADOW_TLS_PASSWORD);
+        let config =
+            ss_base_config(&server, stls_port, socks_port, plugin_yaml.as_str());

Also applies to: 921-925

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs` at line 578, The generated
plugin YAML currently hardcodes the string "password" (around the plugin YAML
construction at lines ~921-925); replace that literal with the shared constant
SHADOW_TLS_PASSWORD (declared at top of file) so the plugin YAML uses
SHADOW_TLS_PASSWORD instead of a duplicated literal, updating the YAML
builder/formatting code that emits the password field to reference the constant.

779-780: Don’t silently swallow container cleanup failures.

Using .ok() here hides cleanup errors; if cleanup fails, later tests may flake due to leaked containers/ports. At least log the error.

Proposed fix pattern
-                    c1.cleanup().await.ok();
+                    if let Err(cleanup_err) = c1.cleanup().await {
+                        tracing::warn!(
+                            "failed to cleanup first container after secondary startup failure: {cleanup_err}"
+                        );
+                    }

Also applies to: 829-830, 914-915

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs` around lines 779 - 780, The
cleanup futures for the container (calls like c1.cleanup().await.ok()) are
currently swallowing errors; change each to capture the Result and log failures
before returning (e.g., replace c1.cleanup().await.ok() with something like if
let Err(clean_err) = c1.cleanup().await { log::warn!("container cleanup failed:
{:?}", clean_err); } ), and apply the same change to the other occurrences (the
blocks that return Err(e) after calling cleanup). Reference the
c1.cleanup().await calls and the surrounding error-return paths so each cleanup
error is logged prior to returning Err(e).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs`:
- Around line 699-703: The code uses to_str().unwrap() when building mmdb from
config_helper::test_config_base_dir().join("Country.mmdb"), which can panic on
non-UTF8 paths; change this to a non-panicking conversion (e.g., use
to_string_lossy().into_owned() or explicitly handle the Option from to_str() and
return a Result/test failure) so mmdb is created safely without unwrap; update
the mmdb binding construction accordingly to avoid panics in tests.

---

Nitpick comments:
In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs`:
- Line 578: The generated plugin YAML currently hardcodes the string "password"
(around the plugin YAML construction at lines ~921-925); replace that literal
with the shared constant SHADOW_TLS_PASSWORD (declared at top of file) so the
plugin YAML uses SHADOW_TLS_PASSWORD instead of a duplicated literal, updating
the YAML builder/formatting code that emits the password field to reference the
constant.
- Around line 779-780: The cleanup futures for the container (calls like
c1.cleanup().await.ok()) are currently swallowing errors; change each to capture
the Result and log failures before returning (e.g., replace
c1.cleanup().await.ok() with something like if let Err(clean_err) =
c1.cleanup().await { log::warn!("container cleanup failed: {:?}", clean_err); }
), and apply the same change to the other occurrences (the blocks that return
Err(e) after calling cleanup). Reference the c1.cleanup().await calls and the
surrounding error-return paths so each cleanup error is logged prior to
returning Err(e).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c0ad62c2-83cc-40fe-b064-23499b7b81ba

📥 Commits

Reviewing files that changed from the base of the PR and between d032460 and 2bcddc6.

📒 Files selected for processing (1)
  • clash-lib/src/proxy/shadowsocks/outbound/mod.rs

Comment on lines +699 to +703
let mmdb = config_helper::test_config_base_dir()
.join("Country.mmdb")
.to_str()
.unwrap()
.to_owned();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid panic in test config path conversion.

Line 702 uses to_str().unwrap(), which can panic on non-UTF8 paths and fail the suite before test logic runs.

Proposed fix
-        let mmdb = config_helper::test_config_base_dir()
-            .join("Country.mmdb")
-            .to_str()
-            .unwrap()
-            .to_owned();
+        let mmdb = config_helper::test_config_base_dir()
+            .join("Country.mmdb")
+            .to_string_lossy()
+            .into_owned();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs` around lines 699 - 703, The
code uses to_str().unwrap() when building mmdb from
config_helper::test_config_base_dir().join("Country.mmdb"), which can panic on
non-UTF8 paths; change this to a non-panicking conversion (e.g., use
to_string_lossy().into_owned() or explicitly handle the Option from to_str() and
return a Result/test failure) so mmdb is created safely without unwrap; update
the mmdb binding construction accordingly to avoid panics in tests.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 25, 2026

🚀 TUN Throughput Benchmark Results

📊 Test Environment
Component Details
OS Linux 6.17.0-1010-azure
Architecture x86_64
CPU AMD EPYC 7763 64-Core Processor
CPU Cores 4
Memory 15.61 GB
Kernel 6.17.0-1010-azure
Network Interfaces eth0 (50000 Mbps), docker0, enP4658s1 (50000 Mbps)

Current PR Performance

Metric Value
Throughput 783.26 Mbps
Data Transferred 979.37 MB
Duration 10.00s
Retransmits 0

Comparison with Baseline (master)

Metric Baseline Current Difference
Throughput 6.48 Gbps 0.78 Gbps 🔴 -5.70 Gbps (-87.9%)

⚠️ Warning: Throughput has decreased by more than 5% compared to baseline.

Direct vs TUN Comparison (Local)

Metric Value
Direct Connection 34.19 Gbps
Through TUN 0.78 Gbps
Overhead 97.7%

🤖 Benchmark run on GitHub Actions CI •
Test duration: 10s • Linux x86_64 • 4 cores

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 25, 2026

📊 Proxy Throughput Results

Transport Payload Upload (Mbps) Download (Mbps)
ss-obfs-http 32 MB 1178.9 1160.3
ss-obfs-tls 32 MB 15816.0 15183.2
ss-plain 32 MB 8531.0 6224.8
ss-shadow-tls-v3 32 MB 15865.2 14743.3
ss-v2ray-plugin-ws-tls 32 MB 6869.6 7228.7

Tests ran 5 variant(s) in parallel; each direction transfers the full payload.

Full test log

Download the throughput-results artifact for the full log.

…binding conflicts

E2e tests connect to containers via their internal docker bridge IP, not
via localhost port mappings. Binding host ports in the macOS ephemeral
range (49152-65535) caused EADDRINUSE when Docker tried to reserve those
ports and the OS already had connections using them.

Add DockerTestRunnerBuilder::no_port() which clears all host port
bindings. Use it in all four e2e container builders (get_ss_runner,
get_ss_runner_with_plugin, get_shadowtls_runner, get_obfs_runner) so
Docker never touches a host port for these containers.

Also simplify the obfs/shadowtls builders: the .port(obfs_port) /
.port(stls_port) introduced in the previous commit was a partial
fix (unique ports instead of colliding on 10002) but still hit the
ephemeral-range problem; no_port() is the correct solution.

Verified: all 5 e2e throughput tests pass with --test-threads=8.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
clash-lib/src/proxy/shadowsocks/outbound/mod.rs (1)

619-627: ⚠️ Potential issue | 🟡 Minor

Avoid panics from to_str().unwrap() in test path handling.

This can panic on non-UTF8 paths (cert/key mounts and mmdb path). The same issue at Line 699–Line 703 was already reported previously and is still present.

Proposed fix
@@
-        let cert = test_config_dir.join("example.org.pem");
-        let key = test_config_dir.join("example.org-key.pem");
+        let cert = test_config_dir.join("example.org.pem");
+        let key = test_config_dir.join("example.org-key.pem");
+        let cert = cert.to_string_lossy().into_owned();
+        let key = key.to_string_lossy().into_owned();
@@
-                    cert.to_str().unwrap(),
+                    cert.as_str(),
@@
-                    key.to_str().unwrap(),
+                    key.as_str(),
@@
-        let mmdb = config_helper::test_config_base_dir()
-            .join("Country.mmdb")
-            .to_str()
-            .unwrap()
-            .to_owned();
+        let mmdb = config_helper::test_config_base_dir()
+            .join("Country.mmdb")
+            .to_string_lossy()
+            .into_owned();
#!/bin/bash
# Verify remaining panicking path conversions in this module.
rg -n 'to_str\(\)\.unwrap\(\)' clash-lib/src/proxy/shadowsocks/outbound/mod.rs

Also applies to: 699-703

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs` around lines 619 - 627, The
code currently calls to_str().unwrap() on Path/OsString variables (cert, key,
and mmdb) which can panic for non-UTF8 paths; replace those calls with a
non-panicking conversion such as cert.to_string_lossy().into_owned() and
key.to_string_lossy().into_owned() (and mmdb.to_string_lossy().into_owned())
where the mounts are built (the tuples using cert and key in the mounts array
and the other mmdb usage around the second occurrence at lines ~699–703) so the
test path handling won’t panic on non-UTF8 file paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs`:
- Around line 638-642: When topology is the "no_port" variant, do not fall back
to "host.docker.internal" for ss_ip; instead detect ss_ip.is_none() and return
an explicit error before constructing ss_server_env (the code that currently
does let ss_server_env = format!("SERVER={}:{}",
ss_ip.unwrap_or("host.docker.internal".to_owned()), ss_port))). Replace the
unwrap_or fallback with an early Err when topology indicates .no_port() and
ss_ip is None, and apply the same change to the analogous block around the
ss_server_env construction at lines 666-670 so both container-to-container
no_port code paths fail fast with a clear error.

---

Duplicate comments:
In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs`:
- Around line 619-627: The code currently calls to_str().unwrap() on
Path/OsString variables (cert, key, and mmdb) which can panic for non-UTF8
paths; replace those calls with a non-panicking conversion such as
cert.to_string_lossy().into_owned() and key.to_string_lossy().into_owned() (and
mmdb.to_string_lossy().into_owned()) where the mounts are built (the tuples
using cert and key in the mounts array and the other mmdb usage around the
second occurrence at lines ~699–703) so the test path handling won’t panic on
non-UTF8 file paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6af2fe0f-a6f0-4035-99f0-aa7ff3b8af99

📥 Commits

Reviewing files that changed from the base of the PR and between 2bcddc6 and 065f289.

📒 Files selected for processing (2)
  • clash-lib/src/proxy/shadowsocks/outbound/mod.rs
  • clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs

Comment on lines +638 to +642
let ss_server_env = format!(
"SERVER={}:{}",
ss_ip.unwrap_or("host.docker.internal".to_owned()),
ss_port
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fail fast if SS container IP is missing under no_port() topology.

With .no_port(), the fallback to host.docker.internal:{ss_port} is not a valid route for these container-to-container links. Returning an explicit error here makes failures deterministic.

Proposed fix
@@
-        let ss_server_env = format!(
-            "SERVER={}:{}",
-            ss_ip.unwrap_or("host.docker.internal".to_owned()),
-            ss_port
-        );
+        let ss_ip = ss_ip
+            .ok_or_else(|| anyhow::anyhow!("missing shadowsocks container IP"))?;
+        let ss_server_env = format!("SERVER={ss_ip}:{ss_port}");
@@
-        let ss_server_env = format!(
-            "{}:{}",
-            ss_ip.unwrap_or("host.docker.internal".to_owned()),
-            ss_port
-        );
+        let ss_ip = ss_ip
+            .ok_or_else(|| anyhow::anyhow!("missing shadowsocks container IP"))?;
+        let ss_server_env = format!("{ss_ip}:{ss_port}");

Also applies to: 666-670

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/proxy/shadowsocks/outbound/mod.rs` around lines 638 - 642, When
topology is the "no_port" variant, do not fall back to "host.docker.internal"
for ss_ip; instead detect ss_ip.is_none() and return an explicit error before
constructing ss_server_env (the code that currently does let ss_server_env =
format!("SERVER={}:{}", ss_ip.unwrap_or("host.docker.internal".to_owned()),
ss_port))). Replace the unwrap_or fallback with an early Err when topology
indicates .no_port() and ss_ip is None, and apply the same change to the
analogous block around the ss_server_env construction at lines 666-670 so both
container-to-container no_port code paths fail fast with a clear error.

Both functions are only called from clash_process_e2e_throughput which
requires #[cfg(all(docker_test, throughput_test))]. Using only
#[cfg(docker_test)] caused dead_code errors when building without
throughput_test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 25, 2026

Codecov Report

❌ Patch coverage is 78.64078% with 22 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
clash-lib/src/proxy/transport/simple_obfs/http.rs 86.17% 9 Missing and 4 partials ⚠️
...oxy/utils/test_utils/docker_utils/docker_runner.rs 0.00% 5 Missing ⚠️
clash-lib/src/proxy/converters/shadowsocks.rs 0.00% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

ibigbug and others added 6 commits April 25, 2026 01:44
- TIMEOUT_DURATION 30 → 120s: 2-core CI runners are slow; clash-rs
  startup alone can take 10-20s, leaving no time for the transfer
- wait_for_port socks_port: 30 → 60s to match extended budget
- clash-rs child stdio: null → inherit so crash logs are visible
- proxy-throughput.yml: remove continue-on-error: true
- proxy-throughput.yml: add set -o pipefail so `| tee` does not swallow
  cargo test exit code (the real false-positive cause)
- Post PR comment step: if: always() so it runs even when tests fail

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- V2RayOBFSOption: make 'port' in plugin-opts optional; real Clash
  configs omit it (only the main proxy port matters for the TCP
  connection). Default to 443 when tls=true, 80 otherwise — the value
  is only used for the WebSocket Upgrade Host header.
- proxy-throughput.yml: pre-download Country.mmdb before running tests
  so all 5 parallel clash-rs instances find the file already present and
  skip the concurrent-download race that was corrupting the file or
  causing startup failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e binary

The telemetry feature (included via --all-features) starts
console_subscriber and OpenTelemetry threads that deadlock with
the main thread over a mutex during crypto initialization.  When
multiple clash-rs instances start concurrently this manifests as
a 60 s+ startup hang, causing every parallel test to time out.

Without telemetry the binary starts in < 1 s.  Default features
already include shadowsocks, tuic, wireguard, ssh, and everything
else needed for these tests.

Also flip find_clash_rs_binary() to prefer the release binary
over debug: release is faster (important for throughput numbers)
and is less likely to have been built with --all-features.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
cargo test -p clash-lib sets the test binary's CWD to clash-lib/,
so writing to the relative path 'throughput-results.json' created
the file at clash-lib/throughput-results.json.  The format/upload
steps looked in the workspace root, so the file was never found.

Fix by setting THROUGHPUT_RESULTS_FILE to ${{ github.workspace }}/...
(absolute path) in the Run throughput tests step, and updating the
Format and Upload steps to use the same absolute path.

Also add #[allow(dead_code)] to the socks5_connect and wait_for_port
helpers to silence the dead_code lint when both docker_test and
throughput_test cfg flags are active.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 'Print environment info' step showing OS, CPUs, memory, disk,
  Docker version, and Rust version before running tests
- Echo the absolute results file path at the start of the test step
  so it's visible in the CI log (cargo test -p clash-lib uses
  clash-lib/ as CWD; THROUGHPUT_RESULTS_FILE is now absolute to
  avoid writing to the wrong directory)
- Cross.toml passthrough not needed: throughput tests run natively
  via cargo test, not via cross

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use ${{ github.workspace }}/throughput-results.json at the workflow
level so the absolute path is declared once and reused everywhere,
instead of re-constructing it from a basename variable at each step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ibigbug ibigbug merged commit d91ba18 into master Apr 26, 2026
36 of 37 checks passed
@ibigbug ibigbug deleted the feat/ss-e2e-throughput-tests branch April 26, 2026 05:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant