From 85d3f336de720be6547d6af41531a282676fab0d Mon Sep 17 00:00:00 2001 From: willamhou Date: Sun, 15 Mar 2026 19:25:54 +0800 Subject: [PATCH 1/3] perf(tunnel): reuse HTTP client in CustomTunnel health checks Previously, `health_check()` created a new `reqwest::Client` on every call. Since health checks run periodically, this caused unnecessary TLS session setup and connection pool churn. Store a shared client on the struct instead, created once in the constructor with a 5s timeout. Fixes #1039 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/tunnel/custom.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/tunnel/custom.rs b/src/tunnel/custom.rs index 9a2be403d9..81211da891 100644 --- a/src/tunnel/custom.rs +++ b/src/tunnel/custom.rs @@ -27,6 +27,7 @@ pub struct CustomTunnel { url_pattern: Option, proc: SharedProcess, url: SharedUrl, + http_client: reqwest::Client, } impl CustomTunnel { @@ -41,6 +42,10 @@ impl CustomTunnel { url_pattern, proc: new_shared_process(), url: new_shared_url(), + http_client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), } } } @@ -140,9 +145,9 @@ impl Tunnel for CustomTunnel { async fn health_check(&self) -> bool { if let Some(ref url) = self.health_url { - return reqwest::Client::new() + return self + .http_client .get(url) - .timeout(std::time::Duration::from_secs(5)) .send() .await .is_ok(); From 634caeeb4d905bc41f5cbc0da59adbea68c610cf Mon Sep 17 00:00:00 2001 From: willamhou Date: Sun, 15 Mar 2026 19:35:39 +0800 Subject: [PATCH 2/3] refactor: propagate error from Client::builder instead of silent fallback Address review feedback: `unwrap_or_else(|_| Client::new())` silently drops the configured timeout on builder failure. Change `new()` to return `Result` and propagate with `?` instead. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/tunnel/custom.rs | 33 +++++++++++++++++++-------------- src/tunnel/mod.rs | 2 +- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/tunnel/custom.rs b/src/tunnel/custom.rs index 81211da891..9828604946 100644 --- a/src/tunnel/custom.rs +++ b/src/tunnel/custom.rs @@ -1,6 +1,6 @@ //! Custom tunnel via an arbitrary shell command. -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use tokio::io::AsyncBufReadExt; use tokio::process::Command; @@ -35,18 +35,19 @@ impl CustomTunnel { start_command: String, health_url: Option, url_pattern: Option, - ) -> Self { - Self { + ) -> Result { + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .context("failed to create HTTP client for tunnel health checks")?; + Ok(Self { start_command, health_url, url_pattern, proc: new_shared_process(), url: new_shared_url(), - http_client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()), - } + http_client, + }) } } @@ -178,7 +179,7 @@ mod tests { #[tokio::test] async fn empty_command_returns_error() { - let tunnel = CustomTunnel::new(" ".into(), None, None); + let tunnel = CustomTunnel::new(" ".into(), None, None).unwrap(); let result = tunnel.start("127.0.0.1", 8080).await; assert!(result.is_err()); assert!( @@ -191,7 +192,7 @@ mod tests { #[tokio::test] async fn start_without_pattern_returns_local() { - let tunnel = CustomTunnel::new("sleep 1".into(), None, None); + let tunnel = CustomTunnel::new("sleep 1".into(), None, None).unwrap(); let url = tunnel.start("127.0.0.1", 4455).await.unwrap(); assert_eq!(url, "http://127.0.0.1:4455"); tunnel.stop().await.unwrap(); @@ -203,7 +204,8 @@ mod tests { "echo https://public.example".into(), None, Some("public.example".into()), - ); + ) + .unwrap(); let url = tunnel.start("localhost", 9999).await.unwrap(); assert_eq!(url, "https://public.example"); tunnel.stop().await.unwrap(); @@ -218,7 +220,8 @@ mod tests { r"printf http://internal:1234\nhttps://real.tunnel.io/abc\n".into(), None, Some("tunnel.io".into()), - ); + ) + .unwrap(); let url = tunnel.start("localhost", 9999).await.unwrap(); assert_eq!(url, "https://real.tunnel.io/abc"); tunnel.stop().await.unwrap(); @@ -230,7 +233,8 @@ mod tests { "echo http://{host}:{port}".into(), None, Some("http://".into()), - ); + ) + .unwrap(); let url = tunnel.start("10.1.2.3", 4321).await.unwrap(); assert_eq!(url, "http://10.1.2.3:4321"); tunnel.stop().await.unwrap(); @@ -243,7 +247,8 @@ mod tests { "sleep 1".into(), Some("http://192.0.2.1:9999/healthz".into()), None, - ); + ) + .unwrap(); assert!( !tunnel.health_check().await, "Health check should fail for unreachable URL" diff --git a/src/tunnel/mod.rs b/src/tunnel/mod.rs index e6245b9e41..24920bf55f 100644 --- a/src/tunnel/mod.rs +++ b/src/tunnel/mod.rs @@ -171,7 +171,7 @@ pub fn create_tunnel(config: &TunnelProviderConfig) -> Result bail!( From 8f96f85f572ac2435ff664fd6f9db4cea282dac9 Mon Sep 17 00:00:00 2001 From: willamhou Date: Mon, 16 Mar 2026 16:52:52 +0800 Subject: [PATCH 3/3] fix(tunnel): add missing .unwrap() in stdout_drain_prevents_zombie test CustomTunnel::new was changed to return Result but this test callsite was missed. Fixes compilation error. [skip-regression-check] --- src/tunnel/custom.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tunnel/custom.rs b/src/tunnel/custom.rs index 9828604946..28f46bbf3b 100644 --- a/src/tunnel/custom.rs +++ b/src/tunnel/custom.rs @@ -281,7 +281,7 @@ mod tests { // `yes` floods stdout indefinitely; without the drain task the pipe // buffer fills (64 KB) and the child blocks on write(), becoming a // zombie. With draining the child stays alive and stop() can kill it. - let tunnel = CustomTunnel::new("yes".into(), None, None); + let tunnel = CustomTunnel::new("yes".into(), None, None).unwrap(); let url = tunnel.start("127.0.0.1", 19999).await.unwrap(); assert_eq!(url, "http://127.0.0.1:19999");