Skip to content

Commit c3c6b22

Browse files
authored
test(jetsocat): replace flaky socks5_to_jmux proptest with deterministic CLI test (#1718)
The proptest-based integration test was flaky on both Linux and Windows CI: - External network call to rust-lang.org:80 failed when the server returned an unexpected HTTP response (e.g. redirect without HTML body) - Same ports rebound 32 times per run risked TIME_WAIT races Replace with two deterministic rstest cases (TCP and WebSocket) in the testsuite CLI suite. The new test spawns real jetsocat subprocesses and uses an in-process tokio echo server, with no external network dependency.
1 parent 1cbb8b9 commit c3c6b22

8 files changed

Lines changed: 136 additions & 260 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

jetsocat/Cargo.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,3 @@ features = [
8181
"Win32_Globalization",
8282
]
8383

84-
[dev-dependencies]
85-
test-utils = { path = "../crates/test-utils" }
86-
tokio = { version = "1.45", features = ["time"] }
87-
proptest = "1.7"

jetsocat/src/lib.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ use humantime as _;
44
use seahorse as _;
55
use tracing_appender as _;
66
use tracing_subscriber as _;
7-
// Used by tests
8-
#[cfg(test)]
9-
use {proptest as _, test_utils as _};
107

118
#[macro_use]
129
extern crate tracing;

jetsocat/src/main.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@ use tokio_tungstenite as _;
2222
use transport as _;
2323
#[cfg(windows)]
2424
use windows as _;
25-
// Used by tests
26-
#[cfg(test)]
27-
use {proptest as _, test_utils as _};
2825
#[cfg(feature = "rustls")]
2926
use {rustls as _, rustls_native_certs as _};
3027

jetsocat/tests/socks5-to-jmux.rs

Lines changed: 0 additions & 247 deletions
This file was deleted.

testsuite/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] }
3131

3232
[dev-dependencies]
3333
base64 = "0.22"
34+
proxy-socks = { path = "../crates/proxy-socks" }
3435
libsql = { version = "0.9", default-features = false, features = ["core"] }
3536
mcp-proxy.path = "../crates/mcp-proxy"
3637
rstest = "0.25"

testsuite/src/cli.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,48 @@ pub async fn wait_for_tcp_port(port: u16) -> anyhow::Result<()> {
9393
}
9494
}
9595
}
96+
97+
/// Waits until a TCP port on localhost is bound by another process, without connecting to it.
98+
///
99+
/// Use this instead of [`wait_for_tcp_port`] when the target listener accepts only a single
100+
/// connection (e.g. `tcp-listen://` or `ws-listen://` in jetsocat). Connecting to such a
101+
/// listener would consume its one accept slot. Instead, this function attempts to bind the same
102+
/// port itself; `AddrInUse` means the target process has already claimed it.
103+
///
104+
/// Polls every 50ms until the port is seen as bound or 10 seconds elapse.
105+
///
106+
/// # Errors
107+
/// Returns an error if the port is not bound within the timeout.
108+
pub async fn wait_for_port_bound(port: u16) -> anyhow::Result<()> {
109+
use std::io::ErrorKind;
110+
use std::net::{Ipv4Addr, SocketAddr};
111+
use std::time::{Duration, Instant};
112+
113+
let timeout = Duration::from_secs(10);
114+
let poll_interval = Duration::from_millis(50);
115+
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
116+
let start = Instant::now();
117+
118+
loop {
119+
if start.elapsed() > timeout {
120+
anyhow::bail!("port {port} was not bound within {timeout:?}");
121+
}
122+
123+
match tokio::net::TcpListener::bind(addr).await {
124+
// We managed to bind it ourselves — the target hasn't claimed it yet.
125+
// Explicitly drop before awaiting: in async/await state machines, temporaries in
126+
// match arms can be kept alive across the await point, which would leave the port
127+
// bound while we sleep and prevent the target from claiming it.
128+
Ok(listener) => {
129+
drop(listener);
130+
tokio::time::sleep(poll_interval).await;
131+
}
132+
// Someone else owns the port — the target process is ready.
133+
// On Linux this is AddrInUse; on Windows with SO_EXCLUSIVEADDRUSE it is
134+
// PermissionDenied (WSAEACCES).
135+
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => return Ok(()),
136+
// Any other error is unexpected; surface it.
137+
Err(e) => return Err(e.into()),
138+
}
139+
}
140+
}

0 commit comments

Comments
 (0)