1mod error;
50pub mod outbound;
51
52use std::net::{IpAddr, Ipv4Addr, SocketAddr};
53use std::path::Path;
54use std::sync::atomic::Ordering;
55use std::sync::Arc;
56use std::time::Duration;
57
58pub use error::EggressError;
59
60#[derive(Clone)]
64pub struct EggressConfig {
65 source_toml: String,
66}
67
68impl EggressConfig {
69 pub fn from_toml_str(input: &str) -> Result<Self, EggressError> {
71 let config: eggress_config::model::ConfigFile =
72 toml::from_str(input).map_err(|e| EggressError::Config(e.to_string()))?;
73
74 if let Some(version) = config.version {
75 if version != 1 {
76 return Err(EggressError::Config(format!(
77 "unsupported config version: {version}"
78 )));
79 }
80 }
81
82 eggress_config::validate::validate_config(&config).map_err(|errors| {
83 let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
84 EggressError::Config(messages.join("; "))
85 })?;
86
87 let _inner = eggress_config::compile::compile_config(&config)
88 .map_err(|e| EggressError::Config(e.to_string()))?;
89
90 Ok(Self {
91 source_toml: input.to_string(),
92 })
93 }
94
95 pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, EggressError> {
97 let path = path.as_ref();
98 let contents = std::fs::read_to_string(path)
99 .map_err(|e| EggressError::Config(format!("failed to read {path:?}: {e}")))?;
100 Self::from_toml_str(&contents)
101 }
102
103 pub fn source_toml(&self) -> &str {
105 &self.source_toml
106 }
107
108 pub fn to_redacted_toml(&self) -> Result<String, EggressError> {
114 let mut value: toml::Value =
115 toml::from_str(&self.source_toml).map_err(|e| EggressError::Config(e.to_string()))?;
116
117 redact_toml_value(&mut value);
118
119 toml::to_string_pretty(&value).map_err(|e| EggressError::Internal(e.to_string()))
120 }
121}
122
123pub struct EggressService {
128 config: EggressConfig,
129}
130
131impl EggressService {
132 pub fn new(config: EggressConfig) -> Self {
134 Self { config }
135 }
136
137 pub fn from_toml_str(input: &str) -> Result<Self, EggressError> {
139 EggressConfig::from_toml_str(input).map(Self::new)
140 }
141
142 pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, EggressError> {
144 EggressConfig::from_toml_file(path).map(Self::new)
145 }
146
147 pub async fn start(self) -> Result<EggressHandle, EggressError> {
153 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
154 let config_path = write_temp_config(&self.config)?;
155 let config_path_clone = config_path.clone();
156
157 let join = tokio::task::spawn_blocking(move || -> Result<
158 (
159 Arc<eggress_runtime::RuntimeState>,
160 tokio_util::sync::CancellationToken,
161 ),
162 EggressError,
163 > {
164 let mut sup = match eggress_runtime::ServiceSupervisor::start(&config_path_clone) {
165 Ok(sup) => sup,
166 Err(error) => {
167 let _ = std::fs::remove_file(&config_path_clone);
168 return Err(EggressError::Startup(error.to_string()));
169 }
170 };
171
172 let state = sup.state().clone();
173 let token = sup.shutdown_token();
174
175 let run_result = std::thread::Builder::new()
176 .name("eggress-embed-rt".into())
177 .spawn(move || sup.run())
178 .map_err(|error| {
179 let _ = std::fs::remove_file(&config_path_clone);
180 EggressError::Startup(error.to_string())
181 })?;
182
183 let started = std::time::Instant::now();
185 let timeout = Duration::from_secs(30);
186 loop {
187 if state.readiness.load(Ordering::Acquire) {
188 let _ = ready_tx.send(Ok((state.clone(), token.clone())));
189 break;
190 }
191 if started.elapsed() > timeout {
192 token.cancel();
193 let _ = std::fs::remove_file(&config_path_clone);
194 let _ = ready_tx.send(Err(EggressError::Startup(
195 "readiness timeout".to_string(),
196 )));
197 break;
198 }
199 std::thread::sleep(Duration::from_millis(5));
200 }
201
202 match run_result.join() {
204 Ok(Ok(())) => {}
205 Ok(Err(e)) => tracing::debug!(%e, "runtime exited with error"),
206 Err(_) => tracing::debug!("runtime thread panicked"),
207 }
208
209 let _ = std::fs::remove_file(&config_path_clone);
211
212 Ok((state, token))
213 });
214
215 let (state, token) = ready_rx
216 .await
217 .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
218
219 let join = tokio::task::spawn(async move {
220 match join.await {
221 Ok(Ok(_)) => Ok(()),
222 Ok(Err(e)) => Err(EggressError::Startup(format!("startup failed: {e}"))),
223 Err(e) => Err(EggressError::Startup(format!("startup task panicked: {e}"))),
224 }
225 });
226
227 Ok(EggressHandle {
228 state,
229 token: Some(token),
230 _run_handle: None,
231 _config_path: Some(config_path),
232 _runtime_task: Some(join),
233 reload_mutex: std::sync::Mutex::new(()),
234 })
235 }
236
237 pub fn start_blocking(self) -> Result<EggressHandle, EggressError> {
243 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
244 let config_path = write_temp_config(&self.config)?;
245 let config_path_clone = config_path.clone();
246
247 let _thread_handle = std::thread::Builder::new()
248 .name("eggress-embed-rt".into())
249 .spawn(move || {
250 let mut sup = match eggress_runtime::ServiceSupervisor::start(&config_path_clone) {
251 Ok(s) => s,
252 Err(e) => {
253 let _ = std::fs::remove_file(&config_path_clone);
254 let _ = ready_tx.send(Err(EggressError::Startup(e.to_string())));
255 return;
256 }
257 };
258
259 let state = sup.state().clone();
260 let token = sup.shutdown_token();
261
262 let run_handle = std::thread::Builder::new()
263 .name("eggress-embed-run".into())
264 .spawn(move || {
265 if let Err(e) = sup.run() {
266 tracing::error!("supervisor exited with error: {e}");
267 }
268 });
269
270 let run_handle = match run_handle {
271 Ok(h) => h,
272 Err(e) => {
273 let _ = std::fs::remove_file(&config_path_clone);
274 let _ = ready_tx.send(Err(EggressError::Startup(e.to_string())));
275 return;
276 }
277 };
278
279 let started = std::time::Instant::now();
281 let timeout = Duration::from_secs(30);
282 loop {
283 if state.readiness.load(Ordering::Acquire) {
284 let _ = ready_tx.send(Ok((state, token, run_handle, config_path_clone)));
285 break;
286 }
287 if started.elapsed() > timeout {
288 token.cancel();
290 let _ = std::fs::remove_file(&config_path_clone);
291 match run_handle.join() {
292 Ok(()) => {}
293 Err(_) => tracing::debug!("runtime thread panicked"),
294 }
295 let _ =
296 ready_tx.send(Err(EggressError::Startup("readiness timeout".into())));
297 break;
298 }
299 std::thread::sleep(Duration::from_millis(5));
300 }
301 })
302 .map_err(|e| {
303 let _ = std::fs::remove_file(&config_path);
304 EggressError::Startup(e.to_string())
305 })?;
306
307 let (state, token, run_handle, config_path) = ready_rx
308 .recv()
309 .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
310
311 Ok(EggressHandle {
312 state,
313 token: Some(token),
314 _run_handle: Some(run_handle),
315 _config_path: Some(config_path),
316 _runtime_task: None,
317 reload_mutex: std::sync::Mutex::new(()),
318 })
319 }
320
321 #[cfg(feature = "pproxy-compat")]
327 pub fn start_blocking_with_compatibility_options(
328 self,
329 compatibility_options: eggress_runtime::CompatibilityOptions,
330 ) -> Result<EggressHandle, EggressError> {
331 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
332 let source_toml = self.config.source_toml.clone();
333 let rt_config = eggress_config::validate_and_compile_toml_with_warnings(&source_toml)
334 .map(|(config, _)| config)
335 .map_err(|e| EggressError::Config(e.to_string()))?;
336
337 std::thread::Builder::new()
338 .name("eggress-embed-rt".into())
339 .spawn(move || {
340 let mut supervisor =
341 match eggress_runtime::ServiceSupervisor::start_from_config_with_options(
342 rt_config,
343 None,
344 compatibility_options,
345 ) {
346 Ok(supervisor) => supervisor,
347 Err(error) => {
348 let _ = ready_tx.send(Err(EggressError::Startup(error.to_string())));
349 return;
350 }
351 };
352
353 let state = supervisor.state().clone();
354 let token = supervisor.shutdown_token();
355 let run_handle = std::thread::Builder::new()
356 .name("eggress-embed-run".into())
357 .spawn(move || {
358 if let Err(error) = supervisor.run() {
359 tracing::error!("supervisor exited with error: {error}");
360 }
361 });
362
363 let run_handle = match run_handle {
364 Ok(handle) => handle,
365 Err(error) => {
366 let _ = ready_tx.send(Err(EggressError::Startup(error.to_string())));
367 return;
368 }
369 };
370
371 let started = std::time::Instant::now();
372 let timeout = Duration::from_secs(30);
373 loop {
374 if state.readiness.load(Ordering::Acquire) {
375 let _ = ready_tx.send(Ok((state, token, run_handle)));
376 break;
377 }
378 if started.elapsed() > timeout {
379 token.cancel();
380 let _ = run_handle.join();
381 let _ =
382 ready_tx.send(Err(EggressError::Startup("readiness timeout".into())));
383 break;
384 }
385 std::thread::sleep(Duration::from_millis(5));
386 }
387 })
388 .map_err(|e| EggressError::Startup(e.to_string()))?;
389
390 let (state, token, run_handle) = ready_rx
391 .recv()
392 .map_err(|_| EggressError::Startup("startup channel dropped".into()))??;
393
394 Ok(EggressHandle {
395 state,
396 token: Some(token),
397 _run_handle: Some(run_handle),
398 _config_path: None,
399 _runtime_task: None,
400 reload_mutex: std::sync::Mutex::new(()),
401 })
402 }
403}
404
405pub struct EggressHandle {
434 state: Arc<eggress_runtime::RuntimeState>,
435 token: Option<tokio_util::sync::CancellationToken>,
436 _run_handle: Option<std::thread::JoinHandle<()>>,
437 _config_path: Option<String>,
438 _runtime_task: Option<tokio::task::JoinHandle<Result<(), EggressError>>>,
439 reload_mutex: std::sync::Mutex<()>,
440}
441
442impl EggressHandle {
443 pub fn bound_addresses(&self) -> BoundAddresses {
445 let addrs = self
446 .state
447 .listener_addrs
448 .lock()
449 .unwrap_or_else(|e| e.into_inner());
450 let admin = self
451 .state
452 .admin_local_addr
453 .lock()
454 .unwrap_or_else(|e| e.into_inner());
455 let snap = self.state.snapshot.load();
456 let listeners: Vec<ListenerAddress> = snap
457 .listeners
458 .iter()
459 .enumerate()
460 .map(|(idx, lcfg)| ListenerAddress {
461 name: lcfg.name.clone(),
462 addr: listener_addr_or_configured(&addrs, idx, &lcfg.bind),
463 })
464 .collect();
465 BoundAddresses {
466 listeners,
467 admin: *admin,
468 }
469 }
470
471 pub fn status(&self) -> ServiceStatus {
473 let snap = self.state.snapshot.load();
474 let addrs = self
475 .state
476 .listener_addrs
477 .lock()
478 .unwrap_or_else(|e| e.into_inner());
479
480 let listeners: Vec<ListenerStatus> = snap
481 .listeners
482 .iter()
483 .enumerate()
484 .map(|(idx, lcfg)| ListenerStatus {
485 name: lcfg.name.clone(),
486 bind: lcfg.bind.clone(),
487 local_addr: listener_addr_or_configured(&addrs, idx, &lcfg.bind),
488 protocols: lcfg.protocols.iter().map(|p| format!("{p}")).collect(),
489 udp_enabled: lcfg.udp.as_ref().is_some_and(|u| u.enabled),
490 })
491 .collect();
492
493 let udp_active = self
494 .state
495 .udp_metrics
496 .associations_active
497 .load(Ordering::Relaxed);
498
499 ServiceStatus {
500 generation: snap.generation,
501 readiness: self.state.readiness.load(Ordering::Relaxed),
502 active_connections: self.state.active_connections.load(Ordering::Relaxed),
503 uptime_secs: self.state.start_time.elapsed().as_secs(),
504 listener_count: snap.listeners.len(),
505 listeners,
506 udp_associations_active: udp_active,
507 upstream_count: snap.upstreams.len(),
508 }
509 }
510
511 pub fn metrics_text(&self) -> Result<String, EggressError> {
513 Ok(self.state.metrics.render_prometheus())
514 }
515
516 pub fn reload_toml_str(&self, input: &str) -> Result<ReloadOutcome, EggressError> {
521 let _guard = self
522 .reload_mutex
523 .lock()
524 .map_err(|_| EggressError::Reload("concurrent reload in progress".to_string()))?;
525
526 let config: eggress_config::model::ConfigFile =
528 toml::from_str(input).map_err(|e| EggressError::Reload(e.to_string()))?;
529
530 if let Some(version) = config.version {
531 if version != 1 {
532 return Err(EggressError::Reload(format!(
533 "unsupported config version: {version}"
534 )));
535 }
536 }
537
538 eggress_config::validate::validate_config(&config).map_err(|errors| {
539 let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
540 EggressError::Reload(messages.join("; "))
541 })?;
542
543 let new_rt_config = eggress_config::compile::compile_config(&config)
544 .map_err(|e| EggressError::Reload(e.to_string()))?;
545
546 let prev_snapshot = self.state.snapshot.load();
549 eggress_runtime::classify_reload_config(
550 &prev_snapshot.listeners,
551 &prev_snapshot.timeouts,
552 prev_snapshot.admin.as_ref(),
553 &new_rt_config,
554 )
555 .map_err(EggressError::Reload)?;
556
557 let prev_ref: Option<&eggress_runtime::CompiledRuntimeSnapshot> = Some(&prev_snapshot);
558 let new_snapshot =
559 eggress_runtime::snapshot::compile_runtime_snapshot(&new_rt_config, prev_ref)
560 .map_err(|e| EggressError::Reload(format!("snapshot build: {e}")))?;
561
562 let gen = new_snapshot.generation;
563 let upstreams = new_snapshot.upstreams.len();
564
565 let new_snapshot = Arc::new(new_snapshot);
570 self.state.snapshot.store(new_snapshot.clone());
571 self.state.routing.swap_arc(new_snapshot.router.clone());
572 self.state.restart_health_probes();
573
574 self.state.metrics.set_config_generation(gen);
575 self.state.metrics.record_reload(true);
576
577 Ok(ReloadOutcome::Applied {
578 generation: gen,
579 upstreams,
580 })
581 }
582
583 pub fn reload_toml_file(&self, path: impl AsRef<Path>) -> Result<ReloadOutcome, EggressError> {
585 let path = path.as_ref();
586 let contents = std::fs::read_to_string(path)
587 .map_err(|e| EggressError::Reload(format!("failed to read {path:?}: {e}")))?;
588 self.reload_toml_str(&contents)
589 }
590
591 pub fn cancel(&self) {
597 if let Some(token) = self.token.as_ref() {
598 token.cancel();
599 }
600 }
601
602 pub fn cancel_and_cleanup(&mut self) {
607 self.cancel();
608 if let Some(path) = self._config_path.take() {
609 let _ = std::fs::remove_file(&path);
610 }
611 }
612
613 pub async fn shutdown(mut self) -> Result<(), EggressError> {
615 if let Some(token) = self.token.take() {
616 token.cancel();
617 }
618 if let Some(task) = self._runtime_task.take() {
619 let _ = task.await;
620 }
621 if let Some(jh) = self._run_handle.take() {
622 let _ = tokio::task::spawn_blocking(move || {
623 let _ = jh.join();
624 })
625 .await;
626 }
627 if let Some(path) = self._config_path.take() {
628 let _ = std::fs::remove_file(&path);
629 }
630 Ok(())
631 }
632
633 pub fn shutdown_blocking(mut self) -> Result<(), EggressError> {
635 if let Some(token) = self.token.take() {
636 token.cancel();
637 }
638 if let Some(jh) = self._run_handle.take() {
639 let _ = jh.join();
640 }
641 if let Some(task) = self._runtime_task.take() {
642 let rt = tokio::runtime::Runtime::new()
643 .map_err(|e| EggressError::Shutdown(e.to_string()))?;
644 rt.block_on(async {
645 let _ = task.await;
646 });
647 }
648 if let Some(path) = self._config_path.take() {
649 let _ = std::fs::remove_file(&path);
650 }
651 Ok(())
652 }
653}
654
655impl Drop for EggressHandle {
656 fn drop(&mut self) {
664 if let Some(token) = self.token.take() {
665 token.cancel();
666 }
667 if let Some(jh) = self._run_handle.take() {
668 let _ = jh.join();
669 }
670 if let Some(task) = self._runtime_task.take() {
671 let rt = tokio::runtime::Runtime::new().ok();
672 if let Some(rt) = rt {
673 rt.block_on(async {
674 let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
675 });
676 }
677 }
678 if let Some(path) = self._config_path.take() {
679 let _ = std::fs::remove_file(&path);
680 }
681 }
682}
683
684fn listener_addr_or_configured(
685 bound_addrs: &[Option<SocketAddr>],
686 idx: usize,
687 configured_bind: &str,
688) -> SocketAddr {
689 bound_addrs
690 .get(idx)
691 .and_then(|a| *a)
692 .or_else(|| configured_bind.parse().ok())
693 .unwrap_or_else(default_listener_addr)
694}
695
696fn default_listener_addr() -> SocketAddr {
697 SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
698}
699
700#[derive(Debug, Clone)]
702pub struct BoundAddresses {
703 pub listeners: Vec<ListenerAddress>,
705 pub admin: Option<std::net::SocketAddr>,
707}
708
709impl BoundAddresses {
710 pub fn listener(&self, name: &str) -> Option<std::net::SocketAddr> {
712 self.listeners
713 .iter()
714 .find(|l| l.name == name)
715 .map(|l| l.addr)
716 }
717}
718
719#[derive(Debug, Clone)]
721pub struct ListenerAddress {
722 pub name: String,
724 pub addr: std::net::SocketAddr,
726}
727
728#[derive(Debug, Clone)]
730pub struct ListenerStatus {
731 pub name: String,
733 pub bind: String,
735 pub local_addr: std::net::SocketAddr,
737 pub protocols: Vec<String>,
739 pub udp_enabled: bool,
741}
742
743#[derive(Debug, Clone)]
745pub struct ServiceStatus {
746 pub generation: u64,
748 pub readiness: bool,
750 pub active_connections: u64,
752 pub uptime_secs: u64,
754 pub listener_count: usize,
756 pub listeners: Vec<ListenerStatus>,
758 pub udp_associations_active: u64,
760 pub upstream_count: usize,
762}
763
764#[derive(Debug)]
766pub enum ReloadOutcome {
767 Applied {
769 generation: u64,
771 upstreams: usize,
773 },
774}
775
776const REDACTED_SECRET_KEYS: &[&str] = &[
778 "password",
779 "password_env",
780 "secret",
781 "secret_ref",
782 "token",
783 "api_key",
784 "apikey",
785 "credentials",
786];
787
788fn redact_toml_value(value: &mut toml::Value) {
798 redact_toml_value_inner(value);
799}
800
801fn redact_toml_value_inner(value: &mut toml::Value) {
802 match value {
803 toml::Value::Table(table) => {
804 for (key, val) in table.iter_mut() {
805 let lkey = key.to_ascii_lowercase();
806 if REDACTED_SECRET_KEYS.iter().any(|k| lkey == *k) {
807 if let toml::Value::String(_) = val {
808 *val = toml::Value::String("****".to_string());
809 continue;
810 }
811 }
812 redact_toml_value_inner(val);
813 }
814 }
815 toml::Value::Array(items) => {
816 for item in items.iter_mut() {
817 redact_toml_value_inner(item);
818 }
819 }
820 toml::Value::String(s) if looks_like_proxy_uri(s) => {
821 *s = redact_uri(s);
822 }
823 _ => {}
824 }
825}
826
827fn looks_like_proxy_uri(s: &str) -> bool {
830 let Some(colon) = s.find("://") else {
831 return false;
832 };
833 let scheme = &s[..colon];
834 matches!(
835 scheme,
836 "socks5"
837 | "socks4"
838 | "http"
839 | "https"
840 | "ss"
841 | "trojan"
842 | "h2"
843 | "ws"
844 | "wss"
845 | "raw"
846 | "tunnel"
847 | "redir"
848 | "unix"
849 )
850}
851
852fn redact_uri(uri: &str) -> String {
862 if let Some(scheme_end) = uri.find("://") {
863 let rest = &uri[scheme_end + 3..];
864 let mut last_at: Option<usize> = None;
866 let mut bracket_depth = 0u32;
867 for (i, c) in rest.char_indices() {
868 match c {
869 '[' => bracket_depth += 1,
870 ']' => bracket_depth = bracket_depth.saturating_sub(1),
871 '@' if bracket_depth == 0 => last_at = Some(i),
872 _ => {}
873 }
874 }
875 if let Some(at_pos) = last_at {
876 let authority_after = &rest[at_pos + 1..];
877 return format!("{}://****:****@{}", &uri[..scheme_end], authority_after);
878 }
879 }
880 uri.to_string()
881}
882
883fn write_temp_config(config: &EggressConfig) -> Result<String, EggressError> {
889 let dir = std::env::temp_dir();
890 use std::io::Write;
891 let mut file = tempfile::Builder::new()
892 .prefix("eggress-embed-")
893 .suffix(".toml")
894 .tempfile_in(dir)
895 .map_err(|e| EggressError::Config(format!("failed to create temp config: {e}")))?;
896 file.write_all(config.source_toml.as_bytes())
897 .and_then(|_| file.flush())
898 .map_err(|e| EggressError::Config(format!("failed to write temp config: {e}")))?;
899 file.into_temp_path()
900 .keep()
901 .map(|path| path.to_string_lossy().into_owned())
902 .map_err(|e| EggressError::Config(format!("failed to retain temp config: {e}")))
903}
904
905#[cfg(test)]
906mod tests {
907 use std::net::SocketAddr;
908
909 use super::{default_listener_addr, listener_addr_or_configured};
910
911 #[test]
912 fn listener_addr_prefers_bound_address() {
913 let bound: SocketAddr = "127.0.0.1:1234".parse().unwrap();
914
915 assert_eq!(
916 listener_addr_or_configured(&[Some(bound)], 0, "127.0.0.1:5678"),
917 bound
918 );
919 }
920
921 #[test]
922 fn listener_addr_falls_back_to_configured_bind() {
923 let configured: SocketAddr = "127.0.0.1:5678".parse().unwrap();
924
925 assert_eq!(
926 listener_addr_or_configured(&[], 0, "127.0.0.1:5678"),
927 configured
928 );
929 }
930
931 #[test]
932 fn listener_addr_uses_default_for_invalid_configured_bind() {
933 assert_eq!(
934 listener_addr_or_configured(&[], 0, "not an address"),
935 default_listener_addr()
936 );
937 }
938
939 #[cfg(unix)]
940 #[test]
941 fn temp_config_file_is_owner_only() {
942 use std::os::unix::fs::PermissionsExt;
943
944 let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
945 let path = super::write_temp_config(&config).unwrap();
946 let metadata = std::fs::metadata(&path).unwrap();
947 assert_eq!(
948 metadata.permissions().mode() & 0o777,
949 0o600,
950 "temp config carries plaintext credentials and must not be group/world readable"
951 );
952 let _ = std::fs::remove_file(&path);
953 }
954
955 #[test]
956 fn temp_config_files_use_distinct_random_names() {
957 let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
958 let first = super::write_temp_config(&config).unwrap();
959 let second = super::write_temp_config(&config).unwrap();
960 assert_ne!(first, second);
961 let _ = std::fs::remove_file(first);
962 let _ = std::fs::remove_file(second);
963 }
964
965 #[test]
966 fn cancel_removes_temp_config_file() {
967 let config = super::EggressConfig::from_toml_str("version = 1").unwrap();
968 let mut handle = super::EggressService::new(config).start_blocking().unwrap();
969 let path = handle._config_path.clone().unwrap();
970
971 assert!(std::path::Path::new(&path).exists());
972 handle.cancel_and_cleanup();
973 assert!(!std::path::Path::new(&path).exists());
974 }
975}