Skip to content

Commit 612fd49

Browse files
authored
fix: supervise the active instance lifecycle (#495)
1 parent b080de8 commit 612fd49

2 files changed

Lines changed: 281 additions & 36 deletions

File tree

src/config_watcher.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ use std::{
88
env,
99
path::{Path, PathBuf},
1010
};
11-
use tokio::sync::{broadcast, mpsc};
11+
use tokio::{
12+
sync::{broadcast, mpsc},
13+
task::JoinHandle,
14+
};
1215
use tracing::{error, info, instrument};
1316

1417
#[cfg(feature = "notify")]
@@ -95,6 +98,7 @@ impl InstanceConfig for ClientConfig {
9598

9699
pub struct ConfigWatcherHandle {
97100
pub event_rx: mpsc::UnboundedReceiver<ConfigChange>,
101+
task: JoinHandle<Result<()>>,
98102
}
99103

100104
impl ConfigWatcherHandle {
@@ -107,14 +111,26 @@ impl ConfigWatcherHandle {
107111
.send(ConfigChange::General(Box::new(origin_cfg.clone())))
108112
.unwrap();
109113

110-
tokio::spawn(config_watcher(
114+
let task = tokio::spawn(config_watcher(
111115
path.to_owned(),
112116
shutdown_rx,
113117
event_tx,
114118
origin_cfg,
115119
));
116120

117-
Ok(ConfigWatcherHandle { event_rx })
121+
Ok(ConfigWatcherHandle { event_rx, task })
122+
}
123+
124+
pub async fn wait(&mut self) -> Result<()> {
125+
(&mut self.task)
126+
.await
127+
.context("configuration watcher task panicked")?
128+
}
129+
}
130+
131+
impl Drop for ConfigWatcherHandle {
132+
fn drop(&mut self) {
133+
self.task.abort();
118134
}
119135
}
120136

src/lib.rs

Lines changed: 262 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ use cli::KeypairType;
1616
pub use config::Config;
1717
pub use constants::UDP_BUFFER_SIZE;
1818

19-
use anyhow::Result;
20-
use tokio::sync::{broadcast, mpsc};
19+
use anyhow::{anyhow, Context, Result};
20+
use tokio::{
21+
sync::{broadcast, mpsc},
22+
task::{JoinError, JoinSet},
23+
};
2124
use tracing::{debug, info};
2225

2326
#[cfg(feature = "client")]
@@ -78,44 +81,98 @@ pub async fn run(args: Cli, shutdown_rx: broadcast::Receiver<bool>) -> Result<()
7881
// shutdown_tx owns the instance
7982
let (shutdown_tx, _) = broadcast::channel(1);
8083

81-
// (The join handle of the last instance, The service update channel sender)
82-
let mut last_instance: Option<(tokio::task::JoinHandle<_>, mpsc::Sender<ConfigChange>)> = None;
83-
84-
while let Some(e) = cfg_watcher.event_rx.recv().await {
85-
match e {
86-
ConfigChange::General(config) => {
87-
if let Some((i, _)) = last_instance {
88-
info!("General configuration change detected. Restarting...");
89-
shutdown_tx.send(true)?;
90-
i.await??;
84+
// Exactly one instance can be active. JoinSet lets the outer loop observe
85+
// an instance failure without waiting for another configuration event.
86+
let mut instance_tasks = JoinSet::new();
87+
let mut service_update_tx = None;
88+
89+
loop {
90+
tokio::select! {
91+
event = cfg_watcher.event_rx.recv() => {
92+
match event {
93+
Some(ConfigChange::General(config)) => {
94+
if !instance_tasks.is_empty() {
95+
info!("General configuration change detected. Restarting...");
96+
stop_active_instance(&shutdown_tx, &mut instance_tasks).await?;
97+
}
98+
99+
debug!("{:?}", config);
100+
101+
let (update_tx, update_rx) = mpsc::channel(1024);
102+
instance_tasks.spawn(run_instance(
103+
*config,
104+
args.clone(),
105+
shutdown_tx.subscribe(),
106+
update_rx,
107+
));
108+
service_update_tx = Some(update_tx);
109+
}
110+
Some(event) => {
111+
info!("Service change detected. {:?}", event);
112+
if let Some(update_tx) = &service_update_tx {
113+
let _ = update_tx.send(event).await;
114+
}
115+
}
116+
None => {
117+
let watcher_result = cfg_watcher.wait().await;
118+
let instance_result =
119+
stop_active_instance(&shutdown_tx, &mut instance_tasks).await;
120+
return finish_shutdown(watcher_result, instance_result);
121+
}
91122
}
92-
93-
debug!("{:?}", config);
94-
95-
let (service_update_tx, service_update_rx) = mpsc::channel(1024);
96-
97-
last_instance = Some((
98-
tokio::spawn(run_instance(
99-
*config,
100-
args.clone(),
101-
shutdown_tx.subscribe(),
102-
service_update_rx,
103-
)),
104-
service_update_tx,
105-
));
106123
}
107-
ev => {
108-
info!("Service change detected. {:?}", ev);
109-
if let Some((_, service_update_tx)) = &last_instance {
110-
let _ = service_update_tx.send(ev).await;
111-
}
124+
instance_result = instance_tasks.join_next(), if !instance_tasks.is_empty() => {
125+
return unexpected_instance_exit(
126+
instance_result.ok_or_else(|| anyhow!("active instance task disappeared"))?,
127+
);
112128
}
113129
}
114130
}
131+
}
115132

116-
let _ = shutdown_tx.send(true);
133+
fn finish_shutdown(watcher_result: Result<()>, instance_result: Result<()>) -> Result<()> {
134+
match (watcher_result, instance_result) {
135+
(Ok(()), result) => result,
136+
(Err(watcher_error), Ok(())) => Err(watcher_error).context("configuration watcher failed"),
137+
(Err(watcher_error), Err(instance_error)) => Err(instance_error).context(format!(
138+
"configuration watcher also failed: {watcher_error:#}"
139+
)),
140+
}
141+
}
117142

118-
Ok(())
143+
async fn stop_active_instance(
144+
shutdown_tx: &broadcast::Sender<bool>,
145+
instance_tasks: &mut JoinSet<Result<()>>,
146+
) -> Result<()> {
147+
if instance_tasks.is_empty() {
148+
return Ok(());
149+
}
150+
151+
// A failed instance may already have dropped its receiver. Awaiting the
152+
// task below preserves that failure instead of returning "channel closed".
153+
let shutdown_sent = shutdown_tx.send(true).is_ok();
154+
let instance_result = instance_tasks
155+
.join_next()
156+
.await
157+
.ok_or_else(|| anyhow!("active instance task disappeared while shutting down"))?
158+
.context("active instance task panicked")?;
159+
160+
match instance_result {
161+
Ok(()) if shutdown_sent => Ok(()),
162+
Ok(()) => Err(anyhow!(
163+
"active instance exited before receiving the shutdown signal"
164+
)),
165+
Err(error) => Err(error).context("active instance failed while shutting down"),
166+
}
167+
}
168+
169+
fn unexpected_instance_exit(
170+
instance_result: std::result::Result<Result<()>, JoinError>,
171+
) -> Result<()> {
172+
match instance_result.context("active instance task panicked")? {
173+
Ok(()) => Err(anyhow!("active instance exited unexpectedly")),
174+
Err(error) => Err(error).context("active instance exited unexpectedly"),
175+
}
119176
}
120177

121178
async fn run_instance(
@@ -169,6 +226,35 @@ fn determine_run_mode(config: &Config, args: &Cli) -> RunMode {
169226
mod tests {
170227
use super::*;
171228

229+
#[cfg(feature = "server")]
230+
fn server_args(bind_addr: std::net::SocketAddr) -> (tempfile::TempDir, Cli) {
231+
let config_dir = tempfile::tempdir().unwrap();
232+
let config_path = config_dir.path().join("server.toml");
233+
std::fs::write(
234+
&config_path,
235+
format!(
236+
r#"[server]
237+
bind_addr = "{bind_addr}"
238+
239+
[server.transport]
240+
type = "tcp"
241+
242+
[server.services.test]
243+
bind_addr = "127.0.0.1:0"
244+
token = "test-token"
245+
"#,
246+
),
247+
)
248+
.unwrap();
249+
250+
let args = Cli {
251+
config_path: Some(config_path),
252+
server: true,
253+
..Default::default()
254+
};
255+
(config_dir, args)
256+
}
257+
172258
#[test]
173259
fn test_determine_run_mode() {
174260
use config::*;
@@ -256,4 +342,147 @@ mod tests {
256342
assert_eq!(determine_run_mode(&config, &args), t.run_mode);
257343
}
258344
}
345+
346+
#[cfg(feature = "server")]
347+
#[tokio::test]
348+
async fn run_surfaces_instance_startup_failure() {
349+
use std::time::Duration;
350+
use tokio::{net::TcpListener, time};
351+
352+
let occupied_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
353+
let occupied_addr = occupied_listener.local_addr().unwrap();
354+
let (_shutdown_tx, shutdown_rx) = broadcast::channel(1);
355+
let (_config_dir, args) = server_args(occupied_addr);
356+
357+
let result = time::timeout(Duration::from_secs(5), run(args, shutdown_rx))
358+
.await
359+
.expect("startup failure should be reported promptly");
360+
let error = format!(
361+
"{:#}",
362+
result.expect_err("an occupied listener must fail startup")
363+
);
364+
assert!(
365+
error.contains("active instance exited unexpectedly"),
366+
"{error}"
367+
);
368+
assert!(
369+
error.contains("Failed to listen at `server.bind_addr`"),
370+
"{error}"
371+
);
372+
}
373+
374+
#[cfg(feature = "server")]
375+
#[tokio::test]
376+
async fn run_releases_listener_before_shutdown_returns() {
377+
use std::time::Duration;
378+
use tokio::{net::TcpListener, net::TcpStream, time};
379+
380+
let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap();
381+
let bind_addr = reservation.local_addr().unwrap();
382+
drop(reservation);
383+
384+
let (_config_dir, args) = server_args(bind_addr);
385+
let (shutdown_tx, shutdown_rx) = broadcast::channel(1);
386+
let run_task = tokio::spawn(run(args, shutdown_rx));
387+
388+
time::timeout(Duration::from_secs(5), async {
389+
loop {
390+
if let Ok(stream) = TcpStream::connect(bind_addr).await {
391+
drop(stream);
392+
break;
393+
}
394+
time::sleep(Duration::from_millis(10)).await;
395+
}
396+
})
397+
.await
398+
.expect("server listener should start promptly");
399+
400+
shutdown_tx.send(true).unwrap();
401+
time::timeout(Duration::from_secs(5), run_task)
402+
.await
403+
.expect("shutdown should complete promptly")
404+
.expect("rathole task should not panic")
405+
.expect("rathole should shut down cleanly");
406+
407+
TcpListener::bind(bind_addr)
408+
.await
409+
.expect("listener must be released before shutdown returns");
410+
}
411+
412+
#[tokio::test]
413+
async fn shutdown_waits_for_the_active_instance() {
414+
use std::time::Duration;
415+
use tokio::{sync::oneshot, time};
416+
417+
let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1);
418+
let (finish_tx, finish_rx) = oneshot::channel();
419+
let mut instance_tasks = JoinSet::new();
420+
instance_tasks.spawn(async move {
421+
shutdown_rx.recv().await.unwrap();
422+
finish_rx.await.unwrap();
423+
Ok(())
424+
});
425+
426+
let mut stopping = Box::pin(stop_active_instance(&shutdown_tx, &mut instance_tasks));
427+
assert!(
428+
time::timeout(Duration::from_millis(25), &mut stopping)
429+
.await
430+
.is_err(),
431+
"shutdown returned before the active instance finished"
432+
);
433+
434+
finish_tx.send(()).unwrap();
435+
time::timeout(Duration::from_secs(1), stopping)
436+
.await
437+
.expect("shutdown should finish promptly after the instance exits")
438+
.unwrap();
439+
}
440+
441+
#[tokio::test]
442+
async fn restart_does_not_overlap_instances() {
443+
use std::time::Duration;
444+
use tokio::{sync::oneshot, time};
445+
446+
let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1);
447+
let (finish_tx, finish_rx) = oneshot::channel();
448+
let (replacement_started_tx, mut replacement_started_rx) = oneshot::channel();
449+
let mut instance_tasks = JoinSet::new();
450+
instance_tasks.spawn(async move {
451+
shutdown_rx.recv().await.unwrap();
452+
finish_rx.await.unwrap();
453+
Ok(())
454+
});
455+
456+
let mut restarting = Box::pin(async {
457+
stop_active_instance(&shutdown_tx, &mut instance_tasks).await?;
458+
instance_tasks.spawn(async move {
459+
replacement_started_tx.send(()).unwrap();
460+
Ok(())
461+
});
462+
Result::<()>::Ok(())
463+
});
464+
465+
assert!(
466+
time::timeout(Duration::from_millis(25), &mut restarting)
467+
.await
468+
.is_err(),
469+
"restart completed before the old instance finished"
470+
);
471+
assert!(
472+
matches!(
473+
replacement_started_rx.try_recv(),
474+
Err(oneshot::error::TryRecvError::Empty)
475+
),
476+
"replacement started while the old instance was active"
477+
);
478+
479+
finish_tx.send(()).unwrap();
480+
time::timeout(Duration::from_secs(1), restarting)
481+
.await
482+
.expect("restart should continue after the old instance exits")
483+
.unwrap();
484+
replacement_started_rx
485+
.await
486+
.expect("replacement instance should start");
487+
}
259488
}

0 commit comments

Comments
 (0)