use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
use crate::core::error::{ERR_DIRECT_FORBIDDEN, ERR_PROXY_UNREACHABLE};
use crate::core::supervision::task::Backoff;
use super::clients::EgressClients;
use super::config::{EgressConfig, ProxySource};
use super::state::{mask_text, EgressSnapshot, EgressState, EgressStatus};
use super::ProxyResolver;
#[cfg(not(test))]
const TICK: Duration = Duration::from_secs(30);
#[cfg(test)]
const TICK: Duration = Duration::from_millis(20);
pub const HEAL_BACKOFF_INITIAL: Duration = Duration::from_secs(60);
pub const HEAL_BACKOFF_MAX: Duration = Duration::from_secs(900);
#[cfg(not(test))]
fn heal_backoff() -> Backoff {
Backoff::new(HEAL_BACKOFF_INITIAL, HEAL_BACKOFF_MAX)
}
#[cfg(test)]
fn heal_backoff() -> Backoff {
Backoff::new(Duration::from_millis(5), Duration::from_millis(40))
}
#[derive(Clone)]
pub struct SelfHeal {
pub resolver: Option<Arc<dyn ProxyResolver>>,
pub clients: Arc<EgressClients>,
pub base: Arc<EgressConfig>,
pub api_url: String,
pub openlatch_dir: std::path::PathBuf,
}
impl std::fmt::Debug for SelfHeal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SelfHeal")
.field("resolver_wired", &self.resolver.is_some())
.field("api_url", &self.api_url)
.finish_non_exhaustive()
}
}
impl SelfHeal {
fn probe_url(&self) -> String {
format!("{}/api/v1/health", self.api_url.trim_end_matches('/'))
}
}
pub async fn run_egress_monitor(
state: EgressState,
mut shutdown: watch::Receiver<bool>,
heal: Option<SelfHeal>,
) {
let mut published = state.status();
log_transition(&state, None, published);
let mut backoff = heal_backoff();
let mut heal_at: Option<tokio::time::Instant> = None;
let mut declined_manual = false;
loop {
let heal_deadline = heal_at.unwrap_or_else(|| tokio::time::Instant::now() + TICK);
tokio::select! {
_ = shutdown.changed() => return,
_ = state.failure_notify().notified() => {}
_ = tokio::time::sleep(TICK) => {}
_ = tokio::time::sleep_until(heal_deadline), if heal_at.is_some() => {
heal_at = None;
if let Some(heal) = heal.as_ref() {
attempt_self_heal(&state, heal).await;
}
}
}
state.publish_status();
let current = state.status();
if current != published {
log_transition(&state, Some(published), current);
published = current;
}
if current == EgressStatus::Failed {
if heal_at.is_none() {
heal_at = arm_self_heal(&state, heal.as_ref(), &mut backoff, &mut declined_manual);
}
} else {
heal_at = None;
declined_manual = false;
backoff.reset();
}
}
}
fn arm_self_heal(
state: &EgressState,
heal: Option<&SelfHeal>,
backoff: &mut Backoff,
declined_manual: &mut bool,
) -> Option<tokio::time::Instant> {
heal?;
if state.snapshot().source == Some(ProxySource::Manual) {
if !*declined_manual {
*declined_manual = true;
tracing::info!(
target: "egress",
"egress is failing on a manually configured proxy; self-heal will not change it \
-- run `openlatch proxy discover` to look for a working route"
);
}
return None;
}
Some(tokio::time::Instant::now() + backoff.next_delay())
}
async fn attempt_self_heal(state: &EgressState, heal: &SelfHeal) -> bool {
let _probing = state.probe_guard();
let Some(resolver) = heal.resolver.as_ref() else {
tracing::debug!(
target: "egress",
"self-heal has no resolver wired; leaving the route as it is"
);
return false;
};
let candidates = resolver.candidates(&heal.api_url);
let attempted = candidates.len() as u32;
if candidates.is_empty() {
tracing::info!(target: "egress", "self-heal found no proxy candidates");
return no_candidate_worked(state, heal, 0);
}
let probe_url = heal.probe_url();
for candidate in &candidates {
let cfg = heal.base.with_candidate(candidate);
let masked = EgressSnapshot::from_config(&cfg).proxy_url_masked;
let route = masked.as_deref().unwrap_or("direct");
let staged = match heal.clients.stage(&cfg) {
Ok(staged) => staged,
Err(e) => {
tracing::info!(
target: "egress",
source = candidate.source.as_str(),
route,
code = %e.code,
detail = %e.message,
"self-heal candidate rejected"
);
continue;
}
};
match staged.cloud().get(&probe_url).send().await {
Ok(response) => {
heal.clients.install(staged);
state.publish_snapshot(EgressSnapshot::from_config(&cfg));
state.record_ok();
tracing::info!(
target: "egress",
source = candidate.source.as_str(),
route,
status = response.status().as_u16(),
attempts = attempted,
"self-heal switched egress onto a working route"
);
super::shape_memo::emit_if_changed(
&heal.openlatch_dir,
&state.snapshot(),
attempted,
);
return true;
}
Err(e) => {
tracing::info!(
target: "egress",
source = candidate.source.as_str(),
route,
detail = %mask_text(&e.to_string()),
"self-heal candidate did not reach the platform"
);
}
}
}
no_candidate_worked(state, heal, attempted)
}
fn no_candidate_worked(state: &EgressState, heal: &SelfHeal, attempted: u32) -> bool {
if heal.base.allow_direct {
state.record_failure(
ERR_PROXY_UNREACHABLE,
format!("no proxy candidate worked after {attempted} attempt(s)"),
);
} else {
state.record_failure(
ERR_DIRECT_FORBIDDEN,
format!(
"no proxy candidate worked after {attempted} attempt(s), and \
[proxy] allow_direct = false forbids going direct"
),
);
}
false
}
fn log_transition(state: &EgressState, from: Option<EgressStatus>, to: EgressStatus) {
let snapshot = state.snapshot();
let route = snapshot.proxy_url_masked.as_deref().unwrap_or("direct");
let error = state.last_error();
let code = error.as_ref().map(|e| e.code).unwrap_or("");
match to {
EgressStatus::Failed => tracing::warn!(
target: "egress",
code,
route,
consecutive_failures = state.consecutive_failures(),
detail = error.as_ref().map(|e| e.message.as_str()).unwrap_or(""),
"egress failed"
),
EgressStatus::Degraded => tracing::info!(
target: "egress",
route,
warnings = state.warnings().join("; "),
"egress degraded"
),
EgressStatus::Ok if from.is_some() => tracing::info!(
target: "egress",
route,
"egress recovered"
),
EgressStatus::Ok | EgressStatus::Unknown => tracing::debug!(
target: "egress",
route,
proxy_in_use = snapshot.proxy_in_use,
"egress monitor started"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::egress::config::{EnvSource, ProxyToml};
use crate::core::egress::factory::Timeouts;
use crate::core::egress::ProxyCandidate;
use std::sync::atomic::{AtomicU32, Ordering};
struct NoEnv;
impl EnvSource for NoEnv {
fn var(&self, _key: &str) -> Option<String> {
None
}
}
fn resolve(toml: ProxyToml) -> EgressConfig {
EgressConfig::resolve(Some(&toml), &NoEnv, 7443, 7444).expect("resolve")
}
async fn eventually(mut check: impl FnMut() -> bool) -> bool {
for _ in 0..400 {
if check() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
false
}
struct FakeResolver {
candidates: Vec<ProxyCandidate>,
asked: Arc<AtomicU32>,
}
impl FakeResolver {
fn wired(candidates: Vec<ProxyCandidate>) -> (Arc<dyn ProxyResolver>, Arc<AtomicU32>) {
let asked = Arc::new(AtomicU32::new(0));
(
Arc::new(Self {
candidates,
asked: asked.clone(),
}),
asked,
)
}
}
impl ProxyResolver for FakeResolver {
fn candidates(&self, _target: &str) -> Vec<ProxyCandidate> {
self.asked.fetch_add(1, Ordering::Relaxed);
self.candidates.clone()
}
}
async fn spawn_mock_forward_proxy() -> (u16, Arc<AtomicU32>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
let hits = Arc::new(AtomicU32::new(0));
let counter = hits.clone();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let counter = counter.clone();
tokio::spawn(async move {
let mut scratch = [0u8; 2048];
if stream.read(&mut scratch).await.is_ok() {
counter.fetch_add(1, Ordering::Relaxed);
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
let _ = stream.flush().await;
}
});
}
});
(port, hits)
}
fn dead_port() -> u16 {
use std::net::{SocketAddr, TcpStream};
use std::sync::atomic::{AtomicU16, Ordering as AtomicOrdering};
static NEXT: AtomicU16 = AtomicU16::new(20000);
loop {
let port = NEXT.fetch_add(1, AtomicOrdering::Relaxed);
assert!(port < 21000, "exhausted 20000..21000 dead ports");
let addr = SocketAddr::from(([127, 0, 0, 1], port));
if TcpStream::connect_timeout(&addr, Duration::from_millis(50)).is_err() {
return port;
}
}
}
fn heal_for(
resolver: Option<Arc<dyn ProxyResolver>>,
base: EgressConfig,
dir: &std::path::Path,
) -> (SelfHeal, Arc<EgressClients>) {
let clients = Arc::new(EgressClients::new(Timeouts::default(), Timeouts::default()));
(
SelfHeal {
resolver,
clients: clients.clone(),
base: Arc::new(base),
api_url: "http://platform.test.invalid".into(),
openlatch_dir: dir.to_path_buf(),
},
clients,
)
}
#[tokio::test]
async fn the_monitor_survives_a_failure_streak_and_publishes_it() {
let state = EgressState::new(&EgressConfig::direct());
let (_tx, rx) = watch::channel(false);
let task = tokio::spawn(run_egress_monitor(state.clone(), rx, None));
state.record_failure(ERR_PROXY_UNREACHABLE, "connection refused");
state.record_failure(ERR_PROXY_UNREACHABLE, "connection refused");
assert!(
eventually(|| state.snapshot().status == EgressStatus::Failed).await,
"two consecutive failures must publish failed"
);
task.abort();
}
#[tokio::test]
async fn the_monitor_publishes_recovery() {
let state = EgressState::new(&EgressConfig::direct());
let (_tx, rx) = watch::channel(false);
let task = tokio::spawn(run_egress_monitor(state.clone(), rx, None));
state.record_failure(ERR_PROXY_UNREACHABLE, "boom");
state.record_failure(ERR_PROXY_UNREACHABLE, "boom");
assert!(eventually(|| state.snapshot().status == EgressStatus::Failed).await);
state.record_ok();
assert!(
eventually(|| state.snapshot().status == EgressStatus::Ok).await,
"one success must clear the streak"
);
task.abort();
}
#[tokio::test]
async fn the_monitor_returns_on_shutdown() {
let state = EgressState::new(&EgressConfig::direct());
let (tx, rx) = watch::channel(false);
let task = tokio::spawn(run_egress_monitor(state, rx, None));
tx.send(true).expect("send shutdown");
tokio::time::timeout(Duration::from_secs(2), task)
.await
.expect("monitor must return on shutdown")
.expect("monitor task");
}
#[tokio::test]
async fn the_monitor_returns_when_the_sender_is_dropped() {
let state = EgressState::new(&EgressConfig::direct());
let (tx, rx) = watch::channel(false);
let task = tokio::spawn(run_egress_monitor(state, rx, None));
drop(tx);
tokio::time::timeout(Duration::from_secs(2), task)
.await
.expect("monitor must return when the daemon drops the signal")
.expect("monitor task");
}
#[tokio::test]
async fn a_monitor_with_no_credentials_still_publishes_state() {
let state = EgressState::new(&EgressConfig::direct());
let (_tx, rx) = watch::channel(false);
let task = tokio::spawn(run_egress_monitor(state.clone(), rx, None));
assert!(eventually(|| state.snapshot().status == EgressStatus::Ok).await);
assert!(state.is_idle(), "no outcome has ever been recorded");
task.abort();
}
#[test]
fn the_shipped_heal_backoff_is_a_minute_to_fifteen() {
assert_eq!(HEAL_BACKOFF_INITIAL, Duration::from_secs(60));
assert_eq!(HEAL_BACKOFF_MAX, Duration::from_secs(900));
let mut backoff = Backoff::new(HEAL_BACKOFF_INITIAL, HEAL_BACKOFF_MAX);
assert_eq!(
backoff.base(),
Duration::from_secs(60),
"the first pass waits a minute"
);
for _ in 0..20 {
backoff.next_delay();
}
assert_eq!(
backoff.base(),
HEAL_BACKOFF_MAX,
"the curve must settle at the ceiling and never past it"
);
backoff.reset();
assert_eq!(
backoff.base(),
HEAL_BACKOFF_INITIAL,
"a recovered route starts the next streak at the floor"
);
}
#[tokio::test]
async fn without_a_resolver_self_heal_does_nothing_and_says_so() {
let dir = tempfile::tempdir().expect("tempdir");
let state = EgressState::new(&EgressConfig::direct());
let (heal, clients) = heal_for(None, EgressConfig::direct(), dir.path());
clients.apply(&EgressConfig::direct()).expect("seed");
assert!(!attempt_self_heal(&state, &heal).await);
assert_eq!(state.status(), EgressStatus::Ok);
assert!(
state.last_error().is_none(),
"a missing resolver is not a transport failure and must not be recorded as one"
);
assert!(
clients.cloud.current().is_some(),
"the installed client must survive a pass with nothing to try"
);
}
#[tokio::test]
async fn a_working_candidate_is_probed_first_then_installed() {
let dir = tempfile::tempdir().expect("tempdir");
let (proxy_port, proxy_hits) = spawn_mock_forward_proxy().await;
let (resolver, asked) = FakeResolver::wired(vec![
ProxyCandidate {
url: format!("http://127.0.0.1:{}", dead_port()),
source: ProxySource::Env,
},
ProxyCandidate {
url: format!("http://127.0.0.1:{proxy_port}"),
source: ProxySource::Gnome,
},
]);
let state = EgressState::new(&EgressConfig::direct());
let (heal, clients) = heal_for(Some(resolver), EgressConfig::direct(), dir.path());
clients.apply(&EgressConfig::direct()).expect("seed");
assert!(
attempt_self_heal(&state, &heal).await,
"the second candidate works"
);
assert_eq!(asked.load(Ordering::Relaxed), 1, "one ladder walk per pass");
assert!(
proxy_hits.load(Ordering::Relaxed) >= 1,
"the probe must have travelled through the candidate's own route"
);
assert_eq!(
state.status(),
EgressStatus::Ok,
"record_ok fires only from the real request"
);
let snapshot = state.snapshot();
assert_eq!(snapshot.source, Some(ProxySource::Gnome));
assert!(snapshot.proxy_in_use);
assert!(
snapshot
.proxy_url_masked
.as_deref()
.is_some_and(|u| u.contains(&proxy_port.to_string())),
"the published route must be the one that was proved"
);
assert!(
clients.boundary.current().is_some(),
"every consumer handle is swapped, not just the one that probed"
);
}
#[tokio::test]
async fn a_candidate_that_fails_its_probe_is_never_installed() {
let dir = tempfile::tempdir().expect("tempdir");
let (resolver, _) = FakeResolver::wired(vec![ProxyCandidate {
url: format!("http://127.0.0.1:{}", dead_port()),
source: ProxySource::Env,
}]);
let state = EgressState::new(&EgressConfig::direct());
let (heal, clients) = heal_for(Some(resolver), EgressConfig::direct(), dir.path());
clients.apply(&EgressConfig::direct()).expect("seed");
assert!(!attempt_self_heal(&state, &heal).await);
let snapshot = state.snapshot();
assert!(
!snapshot.proxy_in_use && snapshot.source.is_none(),
"a failed candidate must not be published as the route"
);
assert_eq!(
state.last_error().map(|e| e.code),
Some(ERR_PROXY_UNREACHABLE)
);
}
#[tokio::test]
async fn allow_direct_false_ends_the_ladder_at_ol_1227_not_at_direct() {
let dir = tempfile::tempdir().expect("tempdir");
let base = resolve(ProxyToml {
mode: Some("manual".into()),
url: Some("http://proxy.corp:8080".into()),
allow_direct: Some(false),
..Default::default()
});
let (resolver, _) = FakeResolver::wired(vec![ProxyCandidate {
url: format!("http://127.0.0.1:{}", dead_port()),
source: ProxySource::Env,
}]);
let state = EgressState::new(&base);
let (heal, _clients) = heal_for(Some(resolver), base, dir.path());
assert!(!attempt_self_heal(&state, &heal).await);
assert_eq!(
state.last_error().map(|e| e.code),
Some(ERR_DIRECT_FORBIDDEN),
"allow_direct = false must surface OL-1227 when nothing works"
);
}
#[tokio::test]
async fn no_candidates_at_all_still_records_a_failure() {
let dir = tempfile::tempdir().expect("tempdir");
let (resolver, _) = FakeResolver::wired(vec![]);
let state = EgressState::new(&EgressConfig::direct());
let (heal, _clients) = heal_for(Some(resolver), EgressConfig::direct(), dir.path());
assert!(!attempt_self_heal(&state, &heal).await);
assert_eq!(
state.last_error().map(|e| e.code),
Some(ERR_PROXY_UNREACHABLE)
);
}
#[tokio::test]
async fn a_manual_source_is_never_healed() {
let manual = resolve(ProxyToml {
mode: Some("manual".into()),
url: Some("http://proxy.corp:8080".into()),
source: Some("manual".into()),
..Default::default()
});
let (resolver, asked) = FakeResolver::wired(vec![ProxyCandidate {
url: "http://127.0.0.1:1".into(),
source: ProxySource::Env,
}]);
let dir = tempfile::tempdir().expect("tempdir");
let state = EgressState::new(&manual);
let (heal, _clients) = heal_for(Some(resolver), manual, dir.path());
let (_tx, rx) = watch::channel(false);
let task = tokio::spawn(run_egress_monitor(state.clone(), rx, Some(heal)));
state.record_failure(ERR_PROXY_UNREACHABLE, "dead");
state.record_failure(ERR_PROXY_UNREACHABLE, "dead");
assert!(eventually(|| state.snapshot().status == EgressStatus::Failed).await);
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
asked.load(Ordering::Relaxed),
0,
"a manual route must never reach the resolver"
);
task.abort();
}
#[tokio::test]
async fn the_loop_heals_a_failed_route_and_then_stops_discovering() {
let dir = tempfile::tempdir().expect("tempdir");
let (proxy_port, _) = spawn_mock_forward_proxy().await;
let (resolver, asked) = FakeResolver::wired(vec![ProxyCandidate {
url: format!("http://127.0.0.1:{proxy_port}"),
source: ProxySource::Windows,
}]);
let state = EgressState::new(&EgressConfig::direct());
let (heal, clients) = heal_for(Some(resolver), EgressConfig::direct(), dir.path());
clients.apply(&EgressConfig::direct()).expect("seed");
let (_tx, rx) = watch::channel(false);
let task = tokio::spawn(run_egress_monitor(state.clone(), rx, Some(heal)));
state.record_failure(ERR_PROXY_UNREACHABLE, "dead");
state.record_failure(ERR_PROXY_UNREACHABLE, "dead");
assert!(
eventually(|| state.snapshot().source == Some(ProxySource::Windows)).await,
"the loop must heal onto the working candidate"
);
assert!(eventually(|| state.snapshot().status == EgressStatus::Ok).await);
let after_heal = asked.load(Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
asked.load(Ordering::Relaxed),
after_heal,
"a healed route sticks: no further discovery until it fails again"
);
task.abort();
}
#[tokio::test]
async fn a_failing_streak_keeps_retrying_under_backoff() {
let dir = tempfile::tempdir().expect("tempdir");
let (resolver, asked) = FakeResolver::wired(vec![ProxyCandidate {
url: format!("http://127.0.0.1:{}", dead_port()),
source: ProxySource::Env,
}]);
let state = EgressState::new(&EgressConfig::direct());
let (heal, _clients) = heal_for(Some(resolver), EgressConfig::direct(), dir.path());
let (_tx, rx) = watch::channel(false);
let task = tokio::spawn(run_egress_monitor(state.clone(), rx, Some(heal)));
state.record_failure(ERR_PROXY_UNREACHABLE, "dead");
state.record_failure(ERR_PROXY_UNREACHABLE, "dead");
assert!(
eventually(|| asked.load(Ordering::Relaxed) >= 2).await,
"the pass must repeat while the route is failed"
);
task.abort();
}
}