Skip to content

Commit 04b0f35

Browse files
authored
Bound integration test scenarios (#486)
* test: bound integration test scenarios * test: close integration timeout gaps
1 parent bc4c19b commit 04b0f35

3 files changed

Lines changed: 210 additions & 58 deletions

File tree

tests/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Integration tests
22

3+
## Scenario deadlines
4+
5+
Every integration scenario has a 60-second deadline. TLS artifact generation
6+
runs on a dedicated thread under a separate 60-second setup deadline.
7+
Timeout errors identify the config and phase, then managed client and server
8+
tasks are shut down before the test returns.
9+
310
## TLS artifacts
411

512
TLS integration tests generate an isolated CA, server certificate, private

tests/common/tls.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,9 @@ fn set_config_value(config: &mut Value, path: &[&str], value: String) -> Result<
201201
mod tests {
202202
use super::*;
203203

204-
#[test]
205-
fn generates_and_cleans_up_tls_artifacts() -> Result<()> {
206-
let config = TlsTestConfig::from_template("tests/for_tcp/tls_transport.toml")?;
204+
#[tokio::test]
205+
async fn generates_and_cleans_up_tls_artifacts() -> Result<()> {
206+
let config = crate::setup_tls_test_config("tests/for_tcp/tls_transport.toml").await?;
207207
let artifact_dir = config
208208
.path()
209209
.parent()

tests/integration_test.rs

Lines changed: 200 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ use anyhow::{anyhow, Context, Result};
22
use common::{run_rathole_client, PING, PONG};
33
use rand::Rng;
44
use rand::RngCore;
5+
use std::future::Future;
56
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
6-
use std::path::Path;
7+
use std::ops::AsyncFnOnce;
8+
use std::path::{Path, PathBuf};
79
use std::time::Duration;
810
use tokio::{
911
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
1012
net::{TcpStream, UdpSocket},
11-
sync::broadcast,
13+
sync::{broadcast, oneshot},
1214
task::{JoinHandle, JoinSet},
1315
time,
1416
};
@@ -32,8 +34,9 @@ const PINGPONG_SERVER_SOCKET_EXPOSED: &str = "/tmp/rathole_integration_test/ping
3234

3335
const HITTER_NUM: usize = 4;
3436
const TASK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
37+
const INTEGRATION_SCENARIO_TIMEOUT: Duration = Duration::from_secs(60);
3538
#[cfg(any(feature = "native-tls", feature = "rustls"))]
36-
const TLS_TEST_TIMEOUT: Duration = Duration::from_secs(60);
39+
const TLS_SETUP_TIMEOUT: Duration = Duration::from_secs(60);
3740

3841
const PP2_SIG: [u8; 12] = [
3942
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
@@ -208,44 +211,71 @@ async fn socket_stream() -> Result<()> {
208211

209212
#[instrument]
210213
async fn test(config_path: impl AsRef<Path> + std::fmt::Debug, t: Type) -> Result<()> {
211-
test_with_timeout(config_path, t, None).await
212-
}
213-
214-
async fn test_with_timeout(
215-
config_path: impl AsRef<Path>,
216-
t: Type,
217-
timeout: Option<Duration>,
218-
) -> Result<()> {
219214
if cfg!(not(all(feature = "client", feature = "server"))) {
220-
// Skip the test if the client or the server is not enabled
221215
return Ok(());
222216
}
223217

224-
let mut processes = TestProcesses::new(config_path.as_ref().to_path_buf());
225-
let scenario = run_scenario(&mut processes, t);
226-
let scenario_result = match timeout {
227-
Some(timeout) => match time::timeout(timeout, scenario).await {
228-
Ok(result) => result,
229-
Err(_) => Err(anyhow!(
230-
"integration test timed out after {} seconds",
231-
timeout.as_secs()
232-
)),
233-
},
234-
None => scenario.await,
235-
};
218+
run_managed_scenario(
219+
config_path.as_ref().to_path_buf(),
220+
"traffic scenario",
221+
INTEGRATION_SCENARIO_TIMEOUT,
222+
async move |processes| run_scenario(processes, t).await,
223+
)
224+
.await
225+
}
226+
227+
async fn run_managed_scenario(
228+
config_path: PathBuf,
229+
phase: &'static str,
230+
timeout: Duration,
231+
scenario: impl AsyncFnOnce(&mut TestProcesses) -> Result<()>,
232+
) -> Result<()> {
233+
let mut processes = TestProcesses::new(config_path.clone());
234+
let scenario_result =
235+
run_with_timeout(&config_path, phase, timeout, scenario(&mut processes)).await;
236236
let cleanup_result = processes.shutdown().await;
237237

238-
finish_scenario(scenario_result, cleanup_result)
238+
finish_with_cleanup(
239+
scenario_result,
240+
cleanup_result,
241+
"integration-test process cleanup",
242+
)
243+
}
244+
245+
async fn run_with_timeout<T>(
246+
config_path: &Path,
247+
phase: &str,
248+
timeout: Duration,
249+
operation: impl Future<Output = Result<T>>,
250+
) -> Result<T> {
251+
match time::timeout(timeout, operation).await {
252+
Ok(result) => result.with_context(|| {
253+
format!(
254+
"integration test `{}` failed during {phase}",
255+
config_path.display()
256+
)
257+
}),
258+
Err(_) => Err(anyhow!(
259+
"integration test `{}` timed out during {phase} after {timeout:?}",
260+
config_path.display()
261+
)),
262+
}
239263
}
240264

241-
fn finish_scenario(scenario_result: Result<()>, cleanup_result: Result<()>) -> Result<()> {
265+
fn finish_with_cleanup(
266+
scenario_result: Result<()>,
267+
cleanup_result: Result<()>,
268+
cleanup_name: &str,
269+
) -> Result<()> {
242270
match (scenario_result, cleanup_result) {
243271
(Ok(()), Ok(())) => Ok(()),
244-
(Ok(()), Err(cleanup_error)) => Err(cleanup_error),
272+
(Ok(()), Err(cleanup_error)) => {
273+
Err(cleanup_error).with_context(|| format!("{cleanup_name} failed"))
274+
}
245275
(Err(scenario_error), Ok(())) => Err(scenario_error),
246-
(Err(scenario_error), Err(cleanup_error)) => Err(scenario_error.context(format!(
247-
"integration-test process cleanup also failed: {cleanup_error:#}"
248-
))),
276+
(Err(scenario_error), Err(cleanup_error)) => {
277+
Err(scenario_error.context(format!("{cleanup_name} also failed: {cleanup_error:#}")))
278+
}
249279
}
250280
}
251281

@@ -445,20 +475,38 @@ async fn stop_task(
445475
Err(_) => {
446476
let task = task.take().expect("running task should exist");
447477
task.abort();
448-
let _ = task.await;
449478
Err(anyhow!("{name} did not stop within {shutdown_timeout:?}"))
450479
}
451480
}
452481
}
453482

483+
struct NotifyOnDrop(Option<oneshot::Sender<()>>);
484+
485+
impl Drop for NotifyOnDrop {
486+
fn drop(&mut self) {
487+
if let Some(tx) = self.0.take() {
488+
let _ = tx.send(());
489+
}
490+
}
491+
}
492+
454493
#[tokio::test]
455494
async fn stop_task_aborts_an_unresponsive_task() {
456495
let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1);
496+
let (started_tx, started_rx) = oneshot::channel();
497+
let (dropped_tx, dropped_rx) = oneshot::channel();
457498
let mut task = Some(tokio::spawn(async move {
499+
let _notify_on_drop = NotifyOnDrop(Some(dropped_tx));
500+
let _ = started_tx.send(());
458501
let _ = shutdown_rx.recv().await;
459502
std::future::pending::<Result<()>>().await
460503
}));
461504

505+
time::timeout(Duration::from_secs(1), started_rx)
506+
.await
507+
.expect("test task should start promptly")
508+
.expect("test task should report startup");
509+
462510
let result = stop_task(
463511
"test task",
464512
&shutdown_tx,
@@ -469,6 +517,10 @@ async fn stop_task_aborts_an_unresponsive_task() {
469517

470518
assert!(result.is_err());
471519
assert!(task.is_none());
520+
time::timeout(Duration::from_secs(1), dropped_rx)
521+
.await
522+
.expect("aborted task should be dropped promptly")
523+
.expect("drop notification sender should survive until task cleanup");
472524
}
473525

474526
#[tokio::test]
@@ -499,11 +551,102 @@ async fn cancelling_stop_keeps_the_task_managed() {
499551
assert!(task.is_none());
500552
}
501553

554+
#[tokio::test]
555+
async fn scenario_timeout_reports_phase_and_cleans_up_tasks() {
556+
use std::sync::{
557+
atomic::{AtomicUsize, Ordering},
558+
Arc,
559+
};
560+
561+
let shutdown_count = Arc::new(AtomicUsize::new(0));
562+
let scenario_shutdown_count = Arc::clone(&shutdown_count);
563+
let result = run_managed_scenario(
564+
PathBuf::from("tests/stalled-traffic.toml"),
565+
"stalled traffic scenario",
566+
Duration::from_millis(10),
567+
async move |processes| {
568+
let mut client_shutdown_rx = processes.client_shutdown_tx.subscribe();
569+
let client_shutdown_count = Arc::clone(&scenario_shutdown_count);
570+
processes.client = Some(tokio::spawn(async move {
571+
// shutdown() sends twice through a one-slot channel. Production
572+
// shutdown receivers finish on either a value or a lag error.
573+
assert!(matches!(
574+
client_shutdown_rx.recv().await,
575+
Ok(true) | Err(broadcast::error::RecvError::Lagged(_))
576+
));
577+
client_shutdown_count.fetch_add(1, Ordering::SeqCst);
578+
Ok(())
579+
}));
580+
581+
let mut server_shutdown_rx = processes.server_shutdown_tx.subscribe();
582+
let server_shutdown_count = Arc::clone(&scenario_shutdown_count);
583+
processes.server = Some(tokio::spawn(async move {
584+
// See the matching client task above.
585+
assert!(matches!(
586+
server_shutdown_rx.recv().await,
587+
Ok(true) | Err(broadcast::error::RecvError::Lagged(_))
588+
));
589+
server_shutdown_count.fetch_add(1, Ordering::SeqCst);
590+
Ok(())
591+
}));
592+
593+
std::future::pending::<Result<()>>().await
594+
},
595+
)
596+
.await;
597+
598+
let error = format!(
599+
"{:#}",
600+
result.expect_err("stalled scenario should time out")
601+
);
602+
assert!(error.contains("tests/stalled-traffic.toml"));
603+
assert!(error.contains("stalled traffic scenario"));
604+
assert!(error.contains("10ms"));
605+
assert!(!error.contains("cleanup"));
606+
assert_eq!(shutdown_count.load(Ordering::SeqCst), 2);
607+
}
608+
502609
#[cfg(any(feature = "native-tls", feature = "rustls"))]
503610
async fn test_tls(config_template: impl AsRef<Path>, t: Type) -> Result<()> {
504-
let config = common::tls::TlsTestConfig::from_template(config_template)?;
505-
test_with_timeout(config.path(), t, Some(TLS_TEST_TIMEOUT)).await?;
506-
config.close()
611+
if cfg!(not(all(feature = "client", feature = "server"))) {
612+
return Ok(());
613+
}
614+
615+
let config = setup_tls_test_config(config_template).await?;
616+
let scenario_result = test(config.path(), t).await;
617+
let cleanup_result = config.close();
618+
finish_with_cleanup(scenario_result, cleanup_result, "TLS artifact cleanup")
619+
}
620+
621+
#[cfg(any(feature = "native-tls", feature = "rustls"))]
622+
async fn setup_tls_test_config(
623+
config_template: impl AsRef<Path>,
624+
) -> Result<common::tls::TlsTestConfig> {
625+
let template_path = config_template.as_ref().to_path_buf();
626+
let generation_path = template_path.clone();
627+
run_with_timeout(
628+
&template_path,
629+
"TLS artifact setup",
630+
TLS_SETUP_TIMEOUT,
631+
generate_tls_test_config(generation_path),
632+
)
633+
.await
634+
}
635+
636+
#[cfg(any(feature = "native-tls", feature = "rustls"))]
637+
async fn generate_tls_test_config(template_path: PathBuf) -> Result<common::tls::TlsTestConfig> {
638+
let (result_tx, result_rx) = oneshot::channel();
639+
std::thread::Builder::new()
640+
.name("rathole-tls-test-setup".to_owned())
641+
.spawn(move || {
642+
let result = common::tls::TlsTestConfig::from_template(template_path);
643+
let _ = result_tx.send(result);
644+
})
645+
.context("failed to spawn TLS artifact setup thread")?;
646+
647+
result_rx
648+
.await
649+
.context("TLS artifact setup thread panicked")?
507650
}
508651

509652
async fn echo_hitter(addr: &'static str, t: Type) -> Result<()> {
@@ -642,27 +785,29 @@ async fn test_proxy_protocol(config_path: &'static str) -> Result<()> {
642785
return Ok(());
643786
}
644787

645-
let mut processes = TestProcesses::new(config_path.into());
646-
let scenario_result = async {
647-
info!("start the client");
648-
processes.start_client();
649-
time::sleep(Duration::from_secs(1)).await;
650-
processes.check_client_running().await?;
651-
652-
info!("start the server");
653-
processes.start_server();
654-
time::sleep(Duration::from_millis(2500)).await;
655-
processes.check_running().await?;
656-
657-
info!("echo");
658-
tcp_echo_hitter_expect_proxy_protocol(ECHO_SERVER_ADDR_EXPOSED).await?;
659-
660-
info!("pingpong");
661-
tcp_pingpong_hitter(PINGPONG_SERVER_ADDR_EXPOSED).await
662-
}
663-
.await;
664-
let cleanup_result = processes.shutdown().await;
665-
finish_scenario(scenario_result, cleanup_result)
788+
run_managed_scenario(
789+
config_path.into(),
790+
"proxy-protocol traffic scenario",
791+
INTEGRATION_SCENARIO_TIMEOUT,
792+
async |processes| {
793+
info!("start the client");
794+
processes.start_client();
795+
time::sleep(Duration::from_secs(1)).await;
796+
processes.check_client_running().await?;
797+
798+
info!("start the server");
799+
processes.start_server();
800+
time::sleep(Duration::from_millis(2500)).await;
801+
processes.check_running().await?;
802+
803+
info!("echo");
804+
tcp_echo_hitter_expect_proxy_protocol(ECHO_SERVER_ADDR_EXPOSED).await?;
805+
806+
info!("pingpong");
807+
tcp_pingpong_hitter(PINGPONG_SERVER_ADDR_EXPOSED).await
808+
},
809+
)
810+
.await
666811
}
667812

668813
async fn read_proxy_protocol_header(

0 commit comments

Comments
 (0)