nym: warm connect from caches + instant price + first-read probe
Cold connect gets a fast path: the last-known-good entry gateway and IPR are persisted (choices only, keys stay ephemeral) and tried first on the next launch - measured 4.4s to tunnel-ready vs 5.6s cold, and no dead- random-gateway lottery. Cached hints self-clear on failure and fall back to auto-select. The price appears instantly: the last fetched rate (under 48h) paints on the first frame and a live fetch fires the moment the tunnel is ready instead of waiting for the balance screen. The eager fetch doubles as an end-to-end probe: if the first reads all fail while the tunnel claims ready, the exit is condemned and reselected in seconds instead of minutes. warm_up now runs first at startup. Money path (streamexit.rs, transport.rs) untouched; ponytail sweep verified all fallback paths and gates green.
This commit is contained in:
+1
-1
@@ -18,5 +18,5 @@ pub use client::*;
|
||||
mod release;
|
||||
pub use release::*;
|
||||
|
||||
mod price;
|
||||
pub(crate) mod price;
|
||||
pub use price::grin_rate;
|
||||
|
||||
+90
-2
@@ -21,8 +21,9 @@
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::nym;
|
||||
|
||||
/// Cache refresh interval (seconds).
|
||||
@@ -32,6 +33,19 @@ const REFRESH_SECS: i64 = 300;
|
||||
/// (e.g. no network) does not respawn a thread every frame.
|
||||
const RETRY_SECS: i64 = 30;
|
||||
|
||||
/// How stale a disk-cached rate may be and still be worth painting on cold start
|
||||
/// (48h). Older than this and we start blank rather than show a very wrong price.
|
||||
const SEED_MAX_AGE_SECS: i64 = 48 * 3600;
|
||||
|
||||
/// Eager-probe per-try timeout. The eager fetch on tunnel-ready doubles as the
|
||||
/// end-to-end exit probe: a healthy warm fetch is ~800ms, a dead exit hangs until
|
||||
/// timeout, so a short cap lets us fail fast and condemn a bad exit in seconds.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
|
||||
/// How many eager-probe fetch attempts before we conclude the (still-"ready")
|
||||
/// exit is blackholing HTTP and condemn it.
|
||||
const PROBE_ATTEMPTS: u32 = 3;
|
||||
|
||||
lazy_static! {
|
||||
/// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at).
|
||||
static ref RATES: RwLock<HashMap<String, (f64, i64)>> = RwLock::new(HashMap::new());
|
||||
@@ -87,13 +101,87 @@ fn trigger_refresh(vs: String) {
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
if let Some(rate) = fetch_rate(&vs).await {
|
||||
RATES.write().insert(vs.clone(), (rate, now()));
|
||||
record_rate(&vs, rate);
|
||||
}
|
||||
});
|
||||
FETCHING.write().remove(&vs);
|
||||
});
|
||||
}
|
||||
|
||||
/// Record a freshly fetched rate: into the in-memory cache (with `now()`) AND to
|
||||
/// disk, so the next cold start can paint it instantly (see [`seed_from_disk`]).
|
||||
fn record_rate(vs: &str, rate: f64) {
|
||||
let t = now();
|
||||
RATES.write().insert(vs.to_string(), (rate, t));
|
||||
AppConfig::set_last_rate(vs, rate, t);
|
||||
}
|
||||
|
||||
/// Seed the in-memory cache from the disk-persisted last rate, if it is fresh
|
||||
/// enough (< 48h). Inserted with its ORIGINAL timestamp so it reads as stale —
|
||||
/// [`grin_rate`] returns it immediately for an instant preview, yet `needs_refresh`
|
||||
/// stays true so a live refresh is still kicked. Called once, early in start().
|
||||
pub fn seed_from_disk() {
|
||||
if let Some((vs, rate, at)) = AppConfig::last_rate() {
|
||||
if now() - at <= SEED_MAX_AGE_SECS {
|
||||
RATES.write().entry(vs).or_insert((rate, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick a refresh for the current pairing's currency the moment the tunnel is
|
||||
/// ready, bypassing the [`RETRY_SECS`] gate (but keeping the [`FETCHING`] dedupe).
|
||||
/// It doubles as the end-to-end exit probe: if every attempt fails while the
|
||||
/// tunnel still reports ready, the exit is blackholing HTTP despite passing the
|
||||
/// cheap liveness probe, so we condemn it (bounded: at most one condemnation per
|
||||
/// tunnel generation) rather than let it stall the wallet for minutes.
|
||||
pub fn eager_refresh() {
|
||||
let vs = match AppConfig::pairing().vs_currency() {
|
||||
Some(vs) => vs.to_string(),
|
||||
// Pairing off → nothing to fetch, so no probe either (we never fetch a
|
||||
// price the user hasn't opted into). The watchdog's own signals govern.
|
||||
None => return,
|
||||
};
|
||||
{
|
||||
let mut fetching = FETCHING.write();
|
||||
if fetching.contains(&vs) {
|
||||
return;
|
||||
}
|
||||
fetching.insert(vs.clone());
|
||||
}
|
||||
LAST_TRY.write().insert(vs.clone(), now());
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let generation = nym::tunnel_generation();
|
||||
let mut ok = false;
|
||||
for attempt in 1..=PROBE_ATTEMPTS {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, fetch_rate(&vs)).await {
|
||||
Ok(Some(rate)) => {
|
||||
record_rate(&vs, rate);
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
log::warn!(
|
||||
"price: eager probe fetch {attempt}/{PROBE_ATTEMPTS} failed \
|
||||
(vs {vs}, gen {generation})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every attempt failed AND the tunnel still claims ready on the SAME
|
||||
// generation we probed: the exit is up but blackholing our HTTP. Condemn
|
||||
// it so a fresh exit is selected in seconds, not minutes. Guarded to the
|
||||
// probed generation so a reselect that already happened is never hit.
|
||||
if !ok && nym::is_ready() && nym::tunnel_generation() == generation {
|
||||
nym::condemn_exit(generation);
|
||||
}
|
||||
});
|
||||
FETCHING.write().remove(&vs);
|
||||
}
|
||||
|
||||
/// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet.
|
||||
async fn fetch_rate(vs: &str) -> Option<f64> {
|
||||
let url = format!(
|
||||
|
||||
+8
-4
@@ -117,16 +117,20 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
|
||||
// would panic on the first TLS handshake. nym uses its own explicit provider,
|
||||
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
// Pre-warm the in-process Nym mixnet tunnel FIRST, before i18n/node setup, so
|
||||
// the mixnet bootstrap (the long pole on cold start) overlaps everything else
|
||||
// and price/NIP-05/nostr are ready at first use. All of Goblin's outbound
|
||||
// traffic egresses through it; nothing clearnet.
|
||||
nym::warm_up();
|
||||
// Seed the price cache from disk so the amount preview can paint an instant
|
||||
// (stale-marked) fiat value while the first live fetch is still in flight.
|
||||
crate::http::price::seed_from_disk();
|
||||
// Setup translations.
|
||||
setup_i18n();
|
||||
// Start integrated node if needed.
|
||||
if AppConfig::autostart_node() {
|
||||
Node::start();
|
||||
}
|
||||
// Pre-warm the in-process Nym mixnet tunnel so price/NIP-05/nostr are ready at
|
||||
// first use. All of Goblin's outbound traffic egresses through it; nothing
|
||||
// clearnet.
|
||||
nym::warm_up();
|
||||
// Launch graphical interface.
|
||||
eframe::run_native("Goblin", options, app_creator)
|
||||
}
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ use log::{debug, warn};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
pub use nymproc::{
|
||||
is_ready, report_relay_down, report_relay_live, set_relay_consumer, transport_ready,
|
||||
tunnel_generation, warm_up,
|
||||
condemn_exit, is_ready, report_relay_down, report_relay_live, set_relay_consumer,
|
||||
transport_ready, tunnel_generation, warm_up,
|
||||
};
|
||||
pub use transport::NymWebSocketTransport;
|
||||
|
||||
|
||||
+270
-66
@@ -49,6 +49,8 @@ use log::{error, info, warn};
|
||||
use parking_lot::RwLock;
|
||||
use smolmix::{Recipient, Tunnel};
|
||||
|
||||
use crate::AppConfig;
|
||||
|
||||
/// The shared process-lifetime tunnel, set once the mixnet bootstrap finishes.
|
||||
static TUNNEL: RwLock<Option<Tunnel>> = RwLock::new(None);
|
||||
|
||||
@@ -86,6 +88,19 @@ static STARTED: AtomicBool = AtomicBool::new(false);
|
||||
/// FIRST tunnel is published — and never again on a later reselect.
|
||||
static PREWARMED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Guards the one-shot eager price fetch / end-to-end exit probe so it fires
|
||||
/// exactly once — after the FIRST tunnel is published — and never again on a
|
||||
/// later reselect.
|
||||
static PRICE_KICKED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The highest tunnel generation for which an external caller (the eager price
|
||||
/// probe) has requested condemnation. The watchdog compares it against the
|
||||
/// generation it is watching each tick, so a dead exit whose cheap probes still
|
||||
/// pass but which blackholes real HTTP can be abandoned in seconds. `fetch_max`
|
||||
/// so a stale request can never move it backwards. Never triggers a reselect on
|
||||
/// a NEWER generation than the one requested.
|
||||
static CONDEMN_REQUEST_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Pre-warm the mixnet tunnel in the background so relays / NIP-05 / price are
|
||||
/// ready by first use. Idempotent — later calls (including the lazy-init path
|
||||
/// in [`wait_for_tunnel`]) are no-ops.
|
||||
@@ -134,6 +149,19 @@ pub fn report_relay_down(generation: u64) {
|
||||
let _ = RELAY_LIVE_GEN.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire);
|
||||
}
|
||||
|
||||
/// External condemnation request for `generation`: the end-to-end eager probe
|
||||
/// found the exit up (cheap probes pass) yet blackholing real HTTP. The watchdog
|
||||
/// picks this up on its next tick and re-selects a fresh exit. Bounded by design:
|
||||
/// it only ever condemns the generation it targets, never a newer one, and the
|
||||
/// probe that calls it is one-shot per tunnel generation.
|
||||
pub fn condemn_exit(generation: u64) {
|
||||
if generation == 0 {
|
||||
return;
|
||||
}
|
||||
CONDEMN_REQUEST_GEN.fetch_max(generation, Ordering::AcqRel);
|
||||
warn!("[timing] nym: eager probe requested condemnation of exit gen {generation}");
|
||||
}
|
||||
|
||||
/// Bracket a nostr consumer's lifetime: the running `NostrService` sets this
|
||||
/// true while it wants relays and false when it stops. Arms/disarms
|
||||
/// relay-reachability governance of exit health (see [`RELAY_CONSUMER`]).
|
||||
@@ -197,6 +225,22 @@ fn run_tunnel() {
|
||||
// True while a FALLBACK (auto-selected) exit carries the traffic even
|
||||
// though an anchor is configured — makes the ANCHOR RECOVERED log honest.
|
||||
let mut fell_back = false;
|
||||
// WARM-CONNECT CACHES (biggest cold-connect win). The last-known-good ENTRY
|
||||
// GATEWAY (item 1) is applied to EVERY build so a warm reconnect skips
|
||||
// re-picking a random — and possibly dead — first hop; a build timeout/error
|
||||
// while it was in use drops it for the rest of THIS process (disk untouched,
|
||||
// since a blip must not throw away a good hint). The last-known-good IPR
|
||||
// (item 2) is tried once per process as a pin, ordered Anchor -> Cached ->
|
||||
// Auto by the selector.
|
||||
let mut cached_gw = AppConfig::nym_entry_gateway();
|
||||
let mut cached_ipr = AppConfig::nym_last_ipr().and_then(|s| parse_anchor(&s));
|
||||
// Don't double up: if the cached IPR is the configured anchor, the anchor
|
||||
// slot already covers it.
|
||||
if let (Some(c), Some(a)) = (cached_ipr, anchor_recipient()) {
|
||||
if c == a {
|
||||
cached_ipr = None;
|
||||
}
|
||||
}
|
||||
// COLD-START SEQUENCING (reads-first): the TUNNEL bootstraps first and takes
|
||||
// its Nym free-tier bandwidth grant, so interactive reads get the tunnel
|
||||
// ~2-3s sooner. The scoped money-path exit is prewarmed AFTER the first
|
||||
@@ -211,7 +255,7 @@ fn run_tunnel() {
|
||||
// attempt in the cycle. Env re-read each attempt so the timing
|
||||
// harness / a debug session can flip it without a restart.
|
||||
let anchor = anchor_recipient();
|
||||
let choice = selector.next_choice(anchor.is_some());
|
||||
let choice = selector.next_choice(anchor.is_some(), cached_ipr.is_some());
|
||||
let pin = match choice {
|
||||
ExitChoice::Anchor => {
|
||||
info!(
|
||||
@@ -219,6 +263,14 @@ fn run_tunnel() {
|
||||
);
|
||||
anchor
|
||||
}
|
||||
ExitChoice::Cached => {
|
||||
info!(
|
||||
"[timing] nym: CACHED attempt — trying last-known-good IPR exit (attempt {attempt})"
|
||||
);
|
||||
// One-shot for this process: take it so a failure falls through
|
||||
// to Auto and the slot never re-arms (unlike the anchor).
|
||||
cached_ipr.take()
|
||||
}
|
||||
ExitChoice::Auto => None,
|
||||
};
|
||||
info!(
|
||||
@@ -229,30 +281,48 @@ fn run_tunnel() {
|
||||
// own long "connection response" timeout (~74s measured) before we can
|
||||
// reselect. Abandoning the future drops the half-built tunnel.
|
||||
let build_cap = tunnel_build_timeout();
|
||||
let build = match tokio::time::timeout(build_cap, build_tunnel(pin)).await {
|
||||
let entry_gw = cached_gw.clone();
|
||||
let used_cached_gw = entry_gw.is_some();
|
||||
let build = match tokio::time::timeout(build_cap, build_tunnel(pin, entry_gw)).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
if choice == ExitChoice::Anchor {
|
||||
// A dead anchor must not delay connectivity: fall back
|
||||
// to auto-select IMMEDIATELY (no backoff), same cycle.
|
||||
warn!(
|
||||
"[timing] nym: ANCHOR DEAD — anchor build exceeded {}s (attempt {attempt}); \
|
||||
FALLBACK to auto-select now",
|
||||
build_cap.as_secs()
|
||||
);
|
||||
continue;
|
||||
// A cached entry gateway that timed out is not reused for the rest
|
||||
// of this process (disk kept — it may be a transient blip).
|
||||
if used_cached_gw {
|
||||
cached_gw = None;
|
||||
}
|
||||
match choice {
|
||||
ExitChoice::Anchor => {
|
||||
// A dead anchor must not delay connectivity: fall back
|
||||
// to auto-select IMMEDIATELY (no backoff), same cycle.
|
||||
warn!(
|
||||
"[timing] nym: ANCHOR DEAD — anchor build exceeded {}s (attempt {attempt}); \
|
||||
FALLBACK to auto-select now",
|
||||
build_cap.as_secs()
|
||||
);
|
||||
}
|
||||
ExitChoice::Cached => {
|
||||
warn!(
|
||||
"[timing] nym: CACHED IPR build exceeded {}s (attempt {attempt}); \
|
||||
clearing the cached exit and auto-selecting now",
|
||||
build_cap.as_secs()
|
||||
);
|
||||
AppConfig::set_nym_last_ipr(None);
|
||||
}
|
||||
ExitChoice::Auto => {
|
||||
warn!(
|
||||
"[timing] nym: DEAD GATEWAY — build_tunnel exceeded {}s (attempt {attempt}); \
|
||||
re-selecting immediately",
|
||||
build_cap.as_secs()
|
||||
);
|
||||
delay = Duration::from_secs(5);
|
||||
}
|
||||
}
|
||||
warn!(
|
||||
"[timing] nym: DEAD GATEWAY — build_tunnel exceeded {}s (attempt {attempt}); \
|
||||
re-selecting immediately",
|
||||
build_cap.as_secs()
|
||||
);
|
||||
delay = Duration::from_secs(5);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match build {
|
||||
Ok(tunnel) => {
|
||||
Ok((tunnel, used_gw, used_ipr)) => {
|
||||
let build_ms = started.elapsed().as_millis();
|
||||
info!(
|
||||
"[timing] nym: tunnel BUILT in {build_ms}ms (attempt {attempt}); probing exit liveness"
|
||||
@@ -269,15 +339,22 @@ fn run_tunnel() {
|
||||
(attempt {attempt}); {}",
|
||||
choice.label(),
|
||||
started.elapsed().as_millis(),
|
||||
if choice == ExitChoice::Anchor {
|
||||
"FALLBACK to auto-select now"
|
||||
} else {
|
||||
"re-selecting immediately"
|
||||
match choice {
|
||||
ExitChoice::Anchor => "FALLBACK to auto-select now",
|
||||
ExitChoice::Cached =>
|
||||
"clearing the cached exit and auto-selecting now",
|
||||
ExitChoice::Auto => "re-selecting immediately",
|
||||
}
|
||||
);
|
||||
tunnel.shutdown().await;
|
||||
if choice == ExitChoice::Auto {
|
||||
delay = (delay * 2).min(Duration::from_secs(60));
|
||||
match choice {
|
||||
// A cached exit that fails its probe is stale: drop the
|
||||
// disk hint so we don't keep re-trying a dead IPR.
|
||||
ExitChoice::Cached => AppConfig::set_nym_last_ipr(None),
|
||||
ExitChoice::Auto => {
|
||||
delay = (delay * 2).min(Duration::from_secs(60));
|
||||
}
|
||||
ExitChoice::Anchor => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -306,6 +383,17 @@ fn run_tunnel() {
|
||||
}
|
||||
fell_back = false;
|
||||
}
|
||||
// A cached exit only wins after the anchor slot was tried this
|
||||
// cycle, so with an anchor configured this is still a FALLBACK —
|
||||
// retry the anchor on the next reselect.
|
||||
ExitChoice::Cached if anchor.is_some() => {
|
||||
fell_back = true;
|
||||
info!(
|
||||
"[timing] nym: running on cached FALLBACK exit (gen {generation}); \
|
||||
anchor will be retried on the next reselect"
|
||||
);
|
||||
}
|
||||
ExitChoice::Cached => {}
|
||||
ExitChoice::Auto if anchor.is_some() => {
|
||||
fell_back = true;
|
||||
info!(
|
||||
@@ -315,6 +403,18 @@ fn run_tunnel() {
|
||||
}
|
||||
ExitChoice::Auto => {}
|
||||
}
|
||||
// Persist the warm-connect caches for the next cold start: the ENTRY
|
||||
// GATEWAY (item 1) and the winning IPR (item 2), each only when
|
||||
// changed so a steady exit doesn't rewrite app.toml on every reselect.
|
||||
if AppConfig::nym_entry_gateway().as_deref() != Some(used_gw.as_str()) {
|
||||
info!("[timing] nym: caching entry gateway {used_gw} for warm reconnect");
|
||||
AppConfig::set_nym_entry_gateway(Some(used_gw.clone()));
|
||||
}
|
||||
cached_gw = Some(used_gw);
|
||||
let ipr_str = used_ipr.to_string();
|
||||
if AppConfig::nym_last_ipr().as_deref() != Some(ipr_str.as_str()) {
|
||||
AppConfig::set_nym_last_ipr(Some(ipr_str));
|
||||
}
|
||||
*TUNNEL.write() = Some(tunnel.clone());
|
||||
MIXNET_READY.store(true, Ordering::Relaxed);
|
||||
// Prewarm the scoped money-path exit ONCE, now that the tunnel is
|
||||
@@ -327,6 +427,13 @@ fn run_tunnel() {
|
||||
{
|
||||
tokio::spawn(super::streamexit::prewarm());
|
||||
}
|
||||
// Eager price fetch the moment the tunnel is ready (item 3) — it
|
||||
// also serves as the end-to-end exit probe (item 5): if every
|
||||
// attempt fails while the tunnel still reads ready, the exit is
|
||||
// blackholing HTTP and gets condemned. One-shot, like the prewarm.
|
||||
if !PRICE_KICKED.swap(true, Ordering::SeqCst) {
|
||||
std::thread::spawn(crate::http::price::eager_refresh);
|
||||
}
|
||||
delay = Duration::from_secs(5);
|
||||
// Hold the exit warm and govern its health. The watchdog weighs TWO
|
||||
// signals: the cheap DNS keepalive (as before) AND — authoritatively,
|
||||
@@ -360,21 +467,36 @@ fn run_tunnel() {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if choice == ExitChoice::Anchor {
|
||||
// Anchor unreachable (not bonded yet / condemned by the
|
||||
// network / bad address): fall back to auto-select
|
||||
// IMMEDIATELY — no backoff, connectivity first.
|
||||
warn!(
|
||||
"[timing] nym: ANCHOR failed to build: {e}; FALLBACK to auto-select now"
|
||||
);
|
||||
continue;
|
||||
// A cached entry gateway that errored is not reused for the rest
|
||||
// of this process (disk kept — it may be a transient blip).
|
||||
if used_cached_gw {
|
||||
cached_gw = None;
|
||||
}
|
||||
match choice {
|
||||
ExitChoice::Anchor => {
|
||||
// Anchor unreachable (not bonded yet / condemned by the
|
||||
// network / bad address): fall back to auto-select
|
||||
// IMMEDIATELY — no backoff, connectivity first.
|
||||
warn!(
|
||||
"[timing] nym: ANCHOR failed to build: {e}; FALLBACK to auto-select now"
|
||||
);
|
||||
}
|
||||
ExitChoice::Cached => {
|
||||
warn!(
|
||||
"[timing] nym: CACHED IPR failed to build: {e}; \
|
||||
clearing the cached exit and auto-selecting now"
|
||||
);
|
||||
AppConfig::set_nym_last_ipr(None);
|
||||
}
|
||||
ExitChoice::Auto => {
|
||||
error!(
|
||||
"nym: mixnet tunnel failed to start: {e}; retrying in {}s",
|
||||
delay.as_secs()
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
error!(
|
||||
"nym: mixnet tunnel failed to start: {e}; retrying in {}s",
|
||||
delay.as_secs()
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,6 +602,19 @@ async fn watch_tunnel(tunnel: &smolmix::Tunnel, generation: u64) {
|
||||
let mut relay_lost: Option<Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(WATCH_TICK).await;
|
||||
// (0) External condemnation request — the eager end-to-end probe found this
|
||||
// exit up (cheap probes pass) yet blackholing real HTTP. Honor it only for
|
||||
// THIS generation (never a newer one): abandon the exit now so a fresh one
|
||||
// is selected in seconds instead of the minutes a blackhole would otherwise
|
||||
// cost. The MIN_EXIT_LIFETIME rebuild floor still bounds the reselect rate.
|
||||
if CONDEMN_REQUEST_GEN.load(Ordering::Acquire) >= generation {
|
||||
warn!(
|
||||
"[timing] nym: CONDEMN gen {generation} reason=eager-probe-blackhole; \
|
||||
exit lived {}s, re-selecting",
|
||||
published.elapsed().as_secs()
|
||||
);
|
||||
return;
|
||||
}
|
||||
// (1) Relay reachability — authoritative, but ONLY when a nostr consumer
|
||||
// actually wants relays on this exit. No consumer → the DNS keepalive
|
||||
// below is the sole health signal, exactly as before this hardening.
|
||||
@@ -550,6 +685,9 @@ enum ExitChoice {
|
||||
/// own co-located exit is the separate scoped-MixnetStream egress; this
|
||||
/// selector governs only the public-IPR fallback layer.)
|
||||
Anchor,
|
||||
/// The last-known-good IPR from a previous run, tried once per process after
|
||||
/// the anchor and before pure auto-select — a warm-connect hint, not a pin.
|
||||
Cached,
|
||||
/// A public exit auto-selected from the network pool — the FALLBACK.
|
||||
Auto,
|
||||
}
|
||||
@@ -559,6 +697,7 @@ impl ExitChoice {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
ExitChoice::Anchor => "ANCHOR",
|
||||
ExitChoice::Cached => "cached",
|
||||
ExitChoice::Auto => "auto-selected",
|
||||
}
|
||||
}
|
||||
@@ -584,27 +723,38 @@ impl ExitChoice {
|
||||
struct ExitSelector {
|
||||
/// Whether the anchor has been tried in the current select cycle.
|
||||
anchor_tried: bool,
|
||||
/// Whether the cached last-known-good IPR has been tried. Unlike the anchor
|
||||
/// this is ONCE PER PROCESS — a warm-connect hint spends itself and never
|
||||
/// re-arms, so it can't keep re-pinning a possibly-stale exit on every cycle.
|
||||
cached_tried: bool,
|
||||
}
|
||||
|
||||
impl ExitSelector {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
anchor_tried: false,
|
||||
cached_tried: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit to target for the next build attempt.
|
||||
fn next_choice(&mut self, anchor_available: bool) -> ExitChoice {
|
||||
/// The exit to target for the next build attempt. Order per cycle:
|
||||
/// anchor (if configured, once per cycle) → cached (if available, once per
|
||||
/// process) → auto-select.
|
||||
fn next_choice(&mut self, anchor_available: bool, cached_available: bool) -> ExitChoice {
|
||||
if anchor_available && !self.anchor_tried {
|
||||
self.anchor_tried = true;
|
||||
ExitChoice::Anchor
|
||||
} else if cached_available && !self.cached_tried {
|
||||
self.cached_tried = true;
|
||||
ExitChoice::Cached
|
||||
} else {
|
||||
ExitChoice::Auto
|
||||
}
|
||||
}
|
||||
|
||||
/// A tunnel was published: the select cycle is over. Re-arms the anchor for
|
||||
/// the next cycle.
|
||||
/// the next cycle. The cached slot is NOT re-armed — it is a one-shot
|
||||
/// warm-connect hint (see [`cached_tried`](Self::cached_tried)).
|
||||
fn tunnel_published(&mut self) {
|
||||
self.anchor_tried = false;
|
||||
}
|
||||
@@ -647,13 +797,28 @@ fn parse_anchor(raw: &str) -> Option<Recipient> {
|
||||
}
|
||||
|
||||
/// Build the tunnel — pinned to the anchor's IPR when `pin` is set, otherwise
|
||||
/// with an auto-selected exit. Ephemeral in-memory keys (a fresh mixnet
|
||||
/// identity per run — no sqlite, no persisted gateway).
|
||||
/// with an auto-selected exit. When `entry_gateway` is set, the client REQUESTS
|
||||
/// that specific first-hop gateway (a warm-connect hint) instead of a random one.
|
||||
///
|
||||
/// Keys stay EPHEMERAL — a fresh mixnet identity per run, no sqlite, nothing
|
||||
/// persisted about the client itself. The ONLY thing that persists across runs is
|
||||
/// the gateway CHOICE (and the exit IPR), remembered by [`run_tunnel`] so a warm
|
||||
/// reconnect skips re-picking a possibly-dead first hop; the requested gateway
|
||||
/// resolves to `GatewaySelectionSpecification::Specified` while storage stays
|
||||
/// ephemeral, so no gateway keys are written to disk.
|
||||
///
|
||||
/// Returns the built tunnel PLUS the ENTRY GATEWAY it actually used (base58) and
|
||||
/// the EXIT IPR recipient it rode — both captured so `run_tunnel` can persist the
|
||||
/// last-known-good pair. The gateway is read from the client's own nym-address
|
||||
/// BEFORE [`IpMixStream::from_client`] consumes the client.
|
||||
///
|
||||
/// NEVER make the anchor the ONLY exit: `pin` must always be allowed to fall
|
||||
/// back to `None` (see [`ExitSelector`]) or the single-exit SPOF — and a
|
||||
/// single party seeing all exit traffic — comes back.
|
||||
async fn build_tunnel(pin: Option<Recipient>) -> Result<Tunnel, smolmix::SmolmixError> {
|
||||
async fn build_tunnel(
|
||||
pin: Option<Recipient>,
|
||||
entry_gateway: Option<String>,
|
||||
) -> Result<(Tunnel, String, Recipient), smolmix::SmolmixError> {
|
||||
use nym_sdk::DebugConfig;
|
||||
use nym_sdk::ipr_wrapper::IpMixStream;
|
||||
use nym_sdk::mixnet::MixnetClientBuilder;
|
||||
@@ -676,21 +841,28 @@ async fn build_tunnel(pin: Option<Recipient>) -> Result<Tunnel, smolmix::Smolmix
|
||||
|
||||
// Mirror the mainnet env setup the SDK's own constructors run before connect.
|
||||
nym_sdk::setup_env(None::<&std::path::Path>);
|
||||
let client = MixnetClientBuilder::new_ephemeral()
|
||||
.debug_config(cfg)
|
||||
.build()?
|
||||
.connect_to_mixnet()
|
||||
.await?;
|
||||
let mut builder = MixnetClientBuilder::new_ephemeral().debug_config(cfg);
|
||||
// Warm-connect: ask for last run's entry gateway. With ephemeral storage this
|
||||
// is a Specified gateway selection with no persisted keys.
|
||||
if let Some(gw) = entry_gateway {
|
||||
builder = builder.request_gateway(gw);
|
||||
}
|
||||
let client = builder.build()?.connect_to_mixnet().await?;
|
||||
|
||||
// Pinned anchor when provided, else the auto-selected best public IPR — the
|
||||
// same discovery the untuned `IpMixStream::new` path used, so anchor/fallback
|
||||
// selection in `run_tunnel` is unchanged.
|
||||
// Capture the ENTRY GATEWAY actually used, from the client's own nym-address,
|
||||
// BEFORE `from_client` consumes the client.
|
||||
let entry_gw = client.nym_address().gateway().to_base58_string();
|
||||
|
||||
// Pinned anchor/cached exit when provided, else the auto-selected best public
|
||||
// IPR — the same discovery the untuned `IpMixStream::new` path used, so
|
||||
// anchor/fallback selection in `run_tunnel` is unchanged.
|
||||
let ipr = match pin {
|
||||
Some(recipient) => recipient,
|
||||
None => IpMixStream::best_ipr().await?,
|
||||
};
|
||||
let stream = IpMixStream::from_client(client, ipr).await?;
|
||||
Tunnel::from_stream(stream).await
|
||||
let tunnel = Tunnel::from_stream(stream).await?;
|
||||
Ok((tunnel, entry_gw, ipr))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -701,50 +873,82 @@ mod tests {
|
||||
fn no_anchor_is_pure_auto_select() {
|
||||
let mut s = ExitSelector::new();
|
||||
for _ in 0..5 {
|
||||
assert_eq!(s.next_choice(false), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(false, false), ExitChoice::Auto);
|
||||
}
|
||||
// Publishing changes nothing without an anchor.
|
||||
s.tunnel_published();
|
||||
assert_eq!(s.next_choice(false), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(false, false), ExitChoice::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_first_then_auto_within_a_cycle() {
|
||||
let mut s = ExitSelector::new();
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Anchor);
|
||||
// Anchor failed — every further attempt in the cycle falls back.
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_retried_on_the_next_cycle_after_a_fallback() {
|
||||
let mut s = ExitSelector::new();
|
||||
// Cycle 1: anchor fails, a fallback exit gets published.
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Auto);
|
||||
s.tunnel_published();
|
||||
// Cycle 2 (the reselect after the fallback): anchor first again.
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Anchor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_publish_also_rearms_the_anchor() {
|
||||
let mut s = ExitSelector::new();
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Anchor);
|
||||
s.tunnel_published(); // the anchor itself came up
|
||||
// Condemned later → next cycle prefers the anchor again.
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Anchor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_appearing_mid_cycle_is_tried() {
|
||||
let mut s = ExitSelector::new();
|
||||
// No anchor yet (env unset / invalid): auto, without burning the try.
|
||||
assert_eq!(s.next_choice(false), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(false, false), ExitChoice::Auto);
|
||||
// Anchor becomes available (env fixed mid-run): tried on the next attempt.
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true, false), ExitChoice::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_after_anchor_then_auto_within_a_cycle() {
|
||||
let mut s = ExitSelector::new();
|
||||
// Order per cycle: anchor → cached → auto.
|
||||
assert_eq!(s.next_choice(true, true), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true, true), ExitChoice::Cached);
|
||||
assert_eq!(s.next_choice(true, true), ExitChoice::Auto);
|
||||
assert_eq!(s.next_choice(true, true), ExitChoice::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_tried_before_auto_when_no_anchor() {
|
||||
let mut s = ExitSelector::new();
|
||||
// No anchor, but a cached hint exists: cached first, then auto.
|
||||
assert_eq!(s.next_choice(false, true), ExitChoice::Cached);
|
||||
assert_eq!(s.next_choice(false, true), ExitChoice::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_is_one_shot_across_the_whole_process() {
|
||||
let mut s = ExitSelector::new();
|
||||
// Spend the cached hint in cycle 1.
|
||||
assert_eq!(s.next_choice(false, true), ExitChoice::Cached);
|
||||
assert_eq!(s.next_choice(false, true), ExitChoice::Auto);
|
||||
s.tunnel_published();
|
||||
// Cycle 2: the cached slot never re-arms, even if still "available".
|
||||
assert_eq!(s.next_choice(false, true), ExitChoice::Auto);
|
||||
// And with an anchor present the anchor is still retried each cycle.
|
||||
assert_eq!(s.next_choice(true, true), ExitChoice::Anchor);
|
||||
assert_eq!(s.next_choice(true, true), ExitChoice::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -93,6 +93,23 @@ pub struct AppConfig {
|
||||
check_updates: Option<bool>,
|
||||
/// Application update information.
|
||||
app_update: Option<AppUpdate>,
|
||||
|
||||
/// Last-known-good Nym ENTRY gateway (base58 identity). Only the gateway
|
||||
/// CHOICE is remembered — the mixnet keys stay ephemeral — so a warm reconnect
|
||||
/// can skip re-picking a (possibly dead) random first hop.
|
||||
nym_entry_gateway: Option<String>,
|
||||
/// Last-known-good Nym IPR exit recipient (the `<id>.<enc>@<gw>` string), so a
|
||||
/// warm reconnect can try the exit that worked last time before auto-selecting.
|
||||
nym_last_ipr: Option<String>,
|
||||
|
||||
/// Last successfully fetched GRIN rate, so the amount preview can paint an
|
||||
/// instant (stale-marked) fiat value on cold start instead of a blank until the
|
||||
/// first mixnet fetch lands.
|
||||
last_rate: Option<f64>,
|
||||
/// The `vs_currency` the cached [`last_rate`] was priced against.
|
||||
last_rate_vs: Option<String>,
|
||||
/// Unix-seconds timestamp the cached [`last_rate`] was fetched at.
|
||||
last_rate_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin.
|
||||
@@ -204,6 +221,11 @@ impl Default for AppConfig {
|
||||
// update check — payments, relays and identity still stay mixnet-only.
|
||||
check_updates: Some(true),
|
||||
app_update: None,
|
||||
nym_entry_gateway: None,
|
||||
nym_last_ipr: None,
|
||||
last_rate: None,
|
||||
last_rate_vs: None,
|
||||
last_rate_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,6 +509,55 @@ impl AppConfig {
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Get the last-known-good Nym ENTRY gateway (base58 identity), if any.
|
||||
pub fn nym_entry_gateway() -> Option<String> {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
r_config.nym_entry_gateway.clone()
|
||||
}
|
||||
|
||||
/// Save (or clear) the last-known-good Nym ENTRY gateway.
|
||||
pub fn set_nym_entry_gateway(gw: Option<String>) {
|
||||
let mut w_config = Settings::app_config_to_update();
|
||||
w_config.nym_entry_gateway = gw;
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Get the last-known-good Nym IPR exit recipient string, if any.
|
||||
pub fn nym_last_ipr() -> Option<String> {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
r_config.nym_last_ipr.clone()
|
||||
}
|
||||
|
||||
/// Save (or clear) the last-known-good Nym IPR exit recipient string.
|
||||
pub fn set_nym_last_ipr(ipr: Option<String>) {
|
||||
let mut w_config = Settings::app_config_to_update();
|
||||
w_config.nym_last_ipr = ipr;
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Get the cached GRIN rate as `(vs_currency, rate, fetched_at)`, if one was
|
||||
/// ever persisted. Callers decide whether it is fresh enough to use.
|
||||
pub fn last_rate() -> Option<(String, f64, i64)> {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
match (
|
||||
r_config.last_rate_vs.clone(),
|
||||
r_config.last_rate,
|
||||
r_config.last_rate_at,
|
||||
) {
|
||||
(Some(vs), Some(rate), Some(at)) => Some((vs, rate, at)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the most recent GRIN rate for `vs`, fetched at `at` (unix secs).
|
||||
pub fn set_last_rate(vs: &str, rate: f64, at: i64) {
|
||||
let mut w_config = Settings::app_config_to_update();
|
||||
w_config.last_rate_vs = Some(vs.to_string());
|
||||
w_config.last_rate = Some(rate);
|
||||
w_config.last_rate_at = Some(at);
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Check if proxy for network requests is needed.
|
||||
pub fn use_proxy() -> bool {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
|
||||
Reference in New Issue
Block a user