fix(web): accept client handshakes concurrently (#2633)
EasyTier Core / pre_job (push) Canceled after 0s
EasyTier GUI / pre_job (push) Canceled after 0s
EasyTier JavaScript Hosts / check (push) Canceled after 0s
EasyTier Mobile / pre_job (push) Canceled after 0s
ohos / pre_job (push) Canceled after 0s
EasyTier Test / pre_job (push) Canceled after 0s
EasyTier Core / build_web (push) Canceled after 0s
EasyTier Core / build (freebsd-13.2-x86_64, 13.2, ubuntu-24.04, x86_64-unknown-freebsd) (push) Canceled after 0s
EasyTier Core / build (linux-aarch64, ubuntu-24.04-arm, aarch64-unknown-linux-musl) (push) Canceled after 0s
EasyTier Core / build (linux-arm, ubuntu-24.04, arm-unknown-linux-musleabi) (push) Canceled after 0s
EasyTier Core / build (linux-armhf, ubuntu-24.04, arm-unknown-linux-musleabihf) (push) Canceled after 0s
EasyTier Core / build (linux-armv7, ubuntu-24.04, armv7-unknown-linux-musleabi) (push) Canceled after 0s
EasyTier Core / build (linux-armv7hf, ubuntu-24.04, armv7-unknown-linux-musleabihf) (push) Canceled after 0s
EasyTier Core / build (linux-loongarch64, ubuntu-24.04, loongarch64-unknown-linux-musl) (push) Canceled after 0s
EasyTier Core / build (linux-mips, ubuntu-24.04, mips-unknown-linux-musl) (push) Canceled after 0s
EasyTier Core / build (linux-mipsel, ubuntu-24.04, mipsel-unknown-linux-musl) (push) Canceled after 0s
EasyTier Core / build (linux-riscv64, ubuntu-24.04, riscv64gc-unknown-linux-musl) (push) Canceled after 0s
EasyTier Core / build (linux-x86_64, ubuntu-24.04, x86_64-unknown-linux-musl) (push) Canceled after 0s
EasyTier Core / build (macos-aarch64, macos-latest, aarch64-apple-darwin) (push) Canceled after 0s
EasyTier Core / build (macos-x86_64, macos-latest, x86_64-apple-darwin) (push) Canceled after 0s
EasyTier Core / build (windows-arm64, windows-11-arm, aarch64-pc-windows-msvc) (push) Canceled after 0s
EasyTier Core / build (windows-i686, windows-latest, i686-pc-windows-msvc) (push) Canceled after 0s
EasyTier Core / build (windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
EasyTier Core / build_magisk (push) Canceled after 0s
EasyTier Core / core-result (push) Canceled after 0s
EasyTier GUI / build-gui (linux-aarch64, aarch64-unknown-linux-gnu, ubuntu-24.04-arm, aarch64-unknown-linux-musl) (push) Canceled after 0s
EasyTier GUI / build-gui (linux-x86_64, x86_64-unknown-linux-gnu, ubuntu-24.04, x86_64-unknown-linux-musl) (push) Canceled after 0s
EasyTier GUI / build-gui (macos-aarch64, aarch64-apple-darwin, macos-latest, aarch64-apple-darwin) (push) Canceled after 0s
EasyTier GUI / build-gui (macos-x86_64, x86_64-apple-darwin, macos-latest, x86_64-apple-darwin) (push) Canceled after 0s
EasyTier GUI / build-gui (windows-arm64, aarch64-pc-windows-msvc, windows-11-arm, aarch64-pc-windows-msvc) (push) Canceled after 0s
EasyTier GUI / build-gui (windows-i686, i686-pc-windows-msvc, windows-latest, i686-pc-windows-msvc) (push) Canceled after 0s
EasyTier GUI / build-gui (windows-x86_64, x86_64-pc-windows-msvc, windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
EasyTier GUI / gui-result (push) Canceled after 0s
EasyTier Mobile / build-mobile (aarch64, aarch64-linux-android) (push) Canceled after 0s
EasyTier Mobile / build-mobile (armv7, armv7-linux-androideabi) (push) Canceled after 0s
EasyTier Mobile / build-mobile (i686, i686-linux-android) (push) Canceled after 0s
EasyTier Mobile / build-mobile (x86_64, x86_64-linux-android) (push) Canceled after 0s
EasyTier Mobile / mobile-result (push) Canceled after 0s
ohos / ohos (push) Canceled after 0s
EasyTier Test / Run linters & check (push) Canceled after 0s
EasyTier Test / Build test (push) Canceled after 0s
EasyTier Test / Test (easytier) (push) Canceled after 0s
EasyTier Test / Test (three_node) (push) Canceled after 0s
EasyTier Test / Test (three_node::subnet_proxy_three_node_test) (push) Canceled after 0s
EasyTier Test / test (push) Canceled after 0s

Move secure tunnel handshakes out of the listener accept path so an idle
connection cannot delay subsequent device sessions. Bound pending
handshakes to 4096 per listener and own them in a JoinSet for
cancellation.

Preserve each in-flight accept future while handling completed
handshakes so listeners with transport upgrades do not lose accepted
connections. Keep session registration, authentication and protocol
timeouts unchanged.

Add TCP regressions for idle-client isolation, manager shutdown, bounded
admission and preserving an unfinished transport accept. The isolation
test fails before the fix and passes afterward.

Validation: all 249 easytier-web tests with --include-ignored pass.
Raise the test process file descriptor limit to 16384 for the explicit
4097-connection capacity test. The default test run skips this stress
test. Cargo fmt --all -- --check and cargo clippy -p easytier-web
--all-targets -- -D warnings also pass.

Domain context impact: none; this changes handshake scheduling only.
Architecture impact: none; ClientManager retains connection ownership.
This commit is contained in:
KKRainbow
2026-10-05 16:25:29 +08:00
committed by GitHub
parent d3a5ae684a
commit 8b7f1f0196
2 changed files with 218 additions and 34 deletions
@@ -0,0 +1,164 @@
use super::*;
use easytier::proto::rpc::standalone::{
RuntimeRpcListener, runtime_rpc_dialer, runtime_rpc_listener,
};
use easytier_core::connectivity::protocol::raw::TunnelDialer;
use std::sync::atomic::AtomicUsize;
use tokio::{io::AsyncReadExt, net::TcpStream, sync::Notify, time::timeout};
#[derive(Debug)]
struct TestListener {
inner: RuntimeRpcListener,
accepted: Arc<AtomicUsize>,
second_accept: Option<Arc<Notify>>,
}
#[async_trait::async_trait]
impl SocketListener for TestListener {
type Accepted = Box<dyn Tunnel>;
async fn listen(&mut self) -> anyhow::Result<()> {
self.inner.listen().await
}
async fn accept(&mut self) -> anyhow::Result<Self::Accepted> {
let tunnel = self.inner.accept().await?;
if self.accepted.fetch_add(1, Ordering::SeqCst) == 1
&& let Some(ready) = &self.second_accept
{
// Model a listener that has accepted a socket but is still
// performing its transport upgrade when another handshake ends.
ready.notified().await;
}
Ok(tunnel)
}
fn local_url(&self) -> url::Url {
self.inner.local_url()
}
}
async fn wait_until(mut condition: impl FnMut() -> bool) {
timeout(Duration::from_secs(1), async {
while !condition() {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.expect("listener did not make progress");
}
async fn manager() -> ClientManager {
ClientManager::new(
Db::memory_db().await,
None,
HeartbeatPolicy::default(),
Arc::new(FeatureFlags::default()),
Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
)),
)
}
#[tokio::test]
async fn slow_handshakes_do_not_block_other_clients() {
let mut manager = manager().await;
let url = manager
.add_listener(runtime_rpc_listener("127.0.0.1:0".parse().unwrap()))
.await
.unwrap();
let addr = url.socket_addrs(|| None).unwrap()[0];
let mut idle_clients = Vec::new();
for _ in 0..4 {
idle_clients.push(TcpStream::connect(addr).await.unwrap());
}
// A client that sends its handshake must not wait for the preceding
// connections' three-second first-packet timeouts.
let client = runtime_rpc_dialer(url).connect().await.unwrap();
let _client = timeout(
Duration::from_secs(1),
web_security::upgrade_client_tunnel(client),
)
.await
.expect("healthy client was blocked by idle connections")
.unwrap();
wait_until(|| manager.client_sessions.len() == 1).await;
// Dropping the manager must also cancel handshakes that are still waiting.
drop(manager);
for mut client in idle_clients {
let mut byte = [0];
assert_eq!(
timeout(Duration::from_secs(1), client.read(&mut byte))
.await
.expect("pending handshake survived manager shutdown")
.unwrap(),
0,
);
}
}
#[tokio::test]
#[ignore = "opens 4097 TCP connections; run explicitly with a sufficient file descriptor limit"]
async fn pending_handshakes_are_bounded_and_release_capacity() {
let mut manager = manager().await;
let accepted = Arc::new(AtomicUsize::new(0));
let url = manager
.add_listener(TestListener {
inner: runtime_rpc_listener("127.0.0.1:0".parse().unwrap()),
accepted: accepted.clone(),
second_accept: None,
})
.await
.unwrap();
let addr = url.socket_addrs(|| None).unwrap()[0];
let mut clients = Vec::new();
for _ in 0..=MAX_PENDING_HANDSHAKES {
clients.push(TcpStream::connect(addr).await.unwrap());
}
wait_until(|| accepted.load(Ordering::SeqCst) >= MAX_PENDING_HANDSHAKES).await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(accepted.load(Ordering::SeqCst), MAX_PENDING_HANDSHAKES);
drop(clients.remove(0));
wait_until(|| accepted.load(Ordering::SeqCst) == MAX_PENDING_HANDSHAKES + 1).await;
}
#[tokio::test]
async fn completed_handshake_does_not_cancel_pending_accept() {
let mut manager = manager().await;
let accepted = Arc::new(AtomicUsize::new(0));
let ready = Arc::new(Notify::new());
let url = manager
.add_listener(TestListener {
inner: runtime_rpc_listener("127.0.0.1:0".parse().unwrap()),
accepted: accepted.clone(),
second_accept: Some(ready.clone()),
})
.await
.unwrap();
let dialer = runtime_rpc_dialer(url);
let first = dialer.connect().await.unwrap();
let second = dialer.connect().await.unwrap();
wait_until(|| accepted.load(Ordering::SeqCst) == 2).await;
let _first = timeout(
Duration::from_secs(1),
web_security::upgrade_client_tunnel(first),
)
.await
.unwrap()
.unwrap();
wait_until(|| manager.client_sessions.len() == 1).await;
ready.notify_one();
let _second = timeout(
Duration::from_secs(1),
web_security::upgrade_client_tunnel(second),
)
.await
.expect("pending accept was cancelled when the first handshake completed")
.unwrap();
wait_until(|| manager.client_sessions.len() == 2).await;
}
+54 -34
View File
@@ -1,3 +1,5 @@
#[cfg(test)]
mod listener_tests;
mod managed_config;
mod runtime_reconcile;
pub mod session;
@@ -42,6 +44,7 @@ const MAX_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
const MIN_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(120);
const HEARTBEAT_TIMEOUT_MARGIN: Duration = Duration::from_secs(5);
const MAX_PENDING_HANDSHAKES: usize = 4096;
#[derive(Debug, Clone, Copy)]
pub(crate) struct HeartbeatPolicy {
@@ -203,40 +206,57 @@ impl ClientManager {
let feature_flags = self.feature_flags.clone();
let webhook_config = self.webhook_config.clone();
self.tasks.spawn(async move {
while let Ok(tunnel) = listener.accept().await {
let (tunnel, secure) = match web_security::accept_or_upgrade_server_tunnel(
tunnel,
)
.await
{
Ok(v) => v,
Err(error) => {
tracing::warn!(%error, "failed to accept secure tunnel, dropping connection");
continue;
}
};
let info = tunnel.info().unwrap();
let client_url: url::Url = info.remote_addr.unwrap().into();
let location = Self::lookup_location(&client_url, geoip_db.clone());
tracing::info!(
"New session from {:?}, secure: {}, location: {:?}",
client_url,
secure,
location
);
let mut session = Session::new(
storage.clone(),
client_url.clone(),
location,
heartbeat_policy,
feature_flags.clone(),
webhook_config.clone(),
next_session_epoch.fetch_add(1, Ordering::Relaxed) + 1,
);
session.serve(tunnel).await;
let session = Arc::new(session);
sessions.insert(client_url, session.clone());
session.mark_route_ready();
let mut handshakes = JoinSet::new();
'accept: loop {
// Some listeners include a WebSocket upgrade in accept(). Keep
// that future alive while processing completed handshakes.
let accepting = listener.accept();
tokio::pin!(accepting);
loop {
let result = tokio::select! {
accepted = &mut accepting, if handshakes.len() < MAX_PENDING_HANDSHAKES => {
let Ok(tunnel) = accepted else { break 'accept };
handshakes.spawn(web_security::accept_or_upgrade_server_tunnel(tunnel));
break;
}
result = handshakes.join_next(), if !handshakes.is_empty() => {
result.unwrap()
}
};
let (tunnel, secure) = match result {
Ok(Ok(value)) => value,
Ok(Err(error)) => {
tracing::warn!(%error, "failed to accept secure tunnel, dropping connection");
continue;
}
Err(error) => {
tracing::warn!(%error, "secure tunnel handshake task failed");
continue;
}
};
let info = tunnel.info().unwrap();
let client_url: url::Url = info.remote_addr.unwrap().into();
let location = Self::lookup_location(&client_url, geoip_db.clone());
tracing::info!(
"New session from {:?}, secure: {}, location: {:?}",
client_url,
secure,
location
);
let mut session = Session::new(
storage.clone(),
client_url.clone(),
location,
heartbeat_policy,
feature_flags.clone(),
webhook_config.clone(),
next_session_epoch.fetch_add(1, Ordering::Relaxed) + 1,
);
session.serve(tunnel).await;
let session = Arc::new(session);
sessions.insert(client_url, session.clone());
session.mark_route_ready();
}
}
listeners_cnt.fetch_sub(1, Ordering::Relaxed);
});