Goblin Build 131 - connectivity sweep
Connect reliability, measured before release: - Cold start: the wallet races two mixnet entry-gateway connects and takes the first up, so a dead random draw no longer costs the full timeout (dead-round rate drops from p to p^2; verified across 30 cold trials). - Dead exits are condemned in ~10s instead of ~32s (fresh-tunnel probe budget 5s x 2 rounds, 4.2x margin over the worst measured healthy probe), removing the 50-70s worst-case connects. Established-tunnel keepalive unchanged. - "Connecting relays..." clears in ~2-4s after saving the relay list (the Connected flag now tracks the real relay-up signal instead of waiting on a 30s catch-up fetch); identity publishes are time-boxed; the onboarding Claim button un-gates equally sooner. - Gateway-race and probe timings are now visible in [timing] logs. Also: the update banner shows Goblin build numbers (not the inherited 0.3.6), and the nip44 crate is consumed from crates.io (v0.3.0) - no sibling checkout needed to build.
This commit is contained in:
Generated
+2
@@ -7480,6 +7480,8 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
[[package]]
|
||||
name = "nip44"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b65fa17d19c3eb6b7b4a908eea19a18ded30f1d95a80a08234f1f80ffe1cb9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chacha20 0.9.1",
|
||||
|
||||
+4
-4
@@ -101,10 +101,10 @@ num-bigint = "0.4.6"
|
||||
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
|
||||
nostr-relay-pool = "0.44"
|
||||
## NIP-44 v3 (+ v2) encryption for the NIP-17 backward-compat extension (G4).
|
||||
## Path dep on the local crate's `v3` working tree — the M0 deliverable, all
|
||||
## upstream test vectors green. Do NOT float this to crates.io until the crate
|
||||
## is published there.
|
||||
nip44 = { path = "../nip44" }
|
||||
## Now published to crates.io as v0.3.0 (the M0 deliverable, all upstream test
|
||||
## vectors green) — no local sibling checkout required. secp256k1 0.31, bridged
|
||||
## to nostr-sdk's 0.29 in wrapv3.rs (see the secp256k1 note below).
|
||||
nip44 = "0.3.0"
|
||||
## Only to construct the key types the `nip44` crate takes: nostr-sdk pins
|
||||
## secp256k1 0.29, the nip44 crate 0.31 — bridged via byte arrays in wrapv3.rs.
|
||||
secp256k1 = "0.31"
|
||||
|
||||
+10
-4
@@ -12,8 +12,8 @@ set -euo pipefail
|
||||
|
||||
platform="${1:-x86_64}"
|
||||
case "${platform}" in
|
||||
x86_64) arch="x86_64-unknown-linux-gnu" ;;
|
||||
arm) arch="aarch64-unknown-linux-gnu" ;;
|
||||
x86_64) arch="x86_64-unknown-linux-gnu"; appimage_arch="x86_64" ;;
|
||||
arm) arch="aarch64-unknown-linux-gnu"; appimage_arch="aarch64" ;;
|
||||
*) echo "Usage: build_release.sh [platform] (platform: 'x86_64' | 'arm')" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -51,7 +51,13 @@ out="target/${arch}/release/Goblin-${platform}.AppImage"
|
||||
rm -f "target/${arch}/release/"*.AppImage
|
||||
# Use the DEV appimagetool + type2 runtime when fetched, else the system tool.
|
||||
appimagetool_bin="${GOBLIN_APPIMAGETOOL:-appimagetool}"
|
||||
# The type2 runtime must match the target arch. env.sh sets GOBLIN_APPIMAGE_RUNTIME
|
||||
# to the x86_64 runtime; for a non-x86_64 target use the sibling runtime-<arch>.
|
||||
runtime_file="${GOBLIN_APPIMAGE_RUNTIME:-}"
|
||||
if [ "${appimage_arch}" != "x86_64" ] && [ -n "${runtime_file}" ]; then
|
||||
runtime_file="$(dirname "${runtime_file}")/runtime-${appimage_arch}"
|
||||
fi
|
||||
runtime_arg=()
|
||||
[ -n "${GOBLIN_APPIMAGE_RUNTIME:-}" ] && runtime_arg=(--runtime-file "${GOBLIN_APPIMAGE_RUNTIME}")
|
||||
ARCH=x86_64 "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
|
||||
[ -n "${runtime_file}" ] && [ -e "${runtime_file}" ] && runtime_arg=(--runtime-file "${runtime_file}")
|
||||
ARCH="${appimage_arch}" "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
|
||||
echo "built: ${out}"
|
||||
|
||||
@@ -49,11 +49,12 @@ fetch_zig() {
|
||||
}
|
||||
|
||||
fetch_appimage() {
|
||||
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && { echo "appimagetool ${AT_VER}: present"; return; }
|
||||
echo "appimage: fetching appimagetool ${AT_VER} + type2 runtime…"
|
||||
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && [ -e "${TC}/runtime-aarch64" ] && { echo "appimagetool ${AT_VER}: present"; return; }
|
||||
echo "appimage: fetching appimagetool ${AT_VER} + type2 runtimes (x86_64 + aarch64)…"
|
||||
dl "${DEV}/appimagetool/releases/download/${AT_VER}/appimagetool-x86_64.AppImage" "${TC}/appimagetool"
|
||||
dl "${DEV}/appimage-type2-runtime/releases/download/${RT_TAG}/runtime-x86_64" "${TC}/runtime-x86_64"
|
||||
chmod +x "${TC}/appimagetool" "${TC}/runtime-x86_64"
|
||||
dl "${DEV}/appimage-type2-runtime/releases/download/${RT_TAG}/runtime-aarch64" "${TC}/runtime-aarch64"
|
||||
chmod +x "${TC}/appimagetool" "${TC}/runtime-x86_64" "${TC}/runtime-aarch64"
|
||||
}
|
||||
|
||||
# Assemble a minimal Android SDK (build-tools + platform + platform-tools) from
|
||||
|
||||
@@ -847,7 +847,7 @@ impl WalletsContent {
|
||||
let ver_text = if let Some(size) = update.size.as_ref() {
|
||||
format!("{} {} ({} MB)", BOOKMARKS, update.version, size)
|
||||
} else {
|
||||
format!("{} {} > {}", BOOKMARKS, crate::VERSION, update.version)
|
||||
format!("{} build{} > {}", BOOKMARKS, crate::BUILD, update.version)
|
||||
};
|
||||
View::ellipsize_text(ui, ver_text, 15.0, Colors::text(false));
|
||||
ui.add_space(1.0);
|
||||
|
||||
+25
-2
@@ -938,6 +938,22 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
"nostr: first relay Connected ~{}ms after connect()",
|
||||
connect_started.elapsed().as_millis()
|
||||
);
|
||||
// Flip the UI "Connected" flag on the REAL relay-up signal
|
||||
// (~2-4s over the exit) instead of gating it behind
|
||||
// publish_identity + the up-to-30s catch-up fetch below: those are
|
||||
// receive-side housekeeping and keep running in the background,
|
||||
// while the relay is already usable the moment it reaches
|
||||
// Connected. Without this, one relay slow to EOSE pinned the
|
||||
// indicator on "Connecting relays…" for ~30s even though the
|
||||
// connection was live in ~2-4s.
|
||||
//
|
||||
// Accepted tradeoff: between here and the 2s status loop taking
|
||||
// over, a relay DROP wouldn't flip the flag back for up to ~30s
|
||||
// (until the post-catch-up re-check re-syncs it to reality) — the
|
||||
// same-order staleness as the old pessimistic gap, just optimistic
|
||||
// instead. The transport watchdog (nymproc) still tracks real exit
|
||||
// health independently of this UI flag.
|
||||
svc_probe.connected.store(true, Ordering::Relaxed);
|
||||
// FAST relay-live report: closes nymproc's relay-readiness
|
||||
// window as soon as the exit is proven to carry relay traffic,
|
||||
// independent of the up-to-30s catch-up fetch below (a slow
|
||||
@@ -1281,8 +1297,15 @@ async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
|
||||
}
|
||||
}
|
||||
for event in &events {
|
||||
if let Err(e) = client.send_event_to(&advertised, event).await {
|
||||
warn!("nostr: publish kind {} failed: {e}", event.kind);
|
||||
// Time-box each publish (mirrors dispatch_dm's SEND_TIMEOUT): this loop is
|
||||
// awaited before the catch-up fetch and the kind:1059 subscription below, so
|
||||
// an untimed send to a stalled relay would delay real incoming-message
|
||||
// delivery. On timeout, warn and move on to the next event — never abort the
|
||||
// identity sequence.
|
||||
match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&advertised, event)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => warn!("nostr: publish kind {} failed: {e}", event.kind),
|
||||
Err(_) => warn!("nostr: publish kind {} timed out", event.kind),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
-17
@@ -474,30 +474,77 @@ const PROBE_ADDRS: [SocketAddr; 2] = [
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 443),
|
||||
];
|
||||
/// Per-target connect wait; a mixnet TCP handshake is a few seconds.
|
||||
/// Per-target connect wait for the PATIENT probe of an ESTABLISHED tunnel
|
||||
/// (watchdog keepalive + condemnation). A mixnet TCP handshake is a few seconds,
|
||||
/// and an exit already in service must NEVER be thrown away over a momentary load
|
||||
/// spike, so this stays deliberately generous at 8s — the pre-existing budget.
|
||||
/// (The just-built-tunnel GATE uses the tighter [`FRESH_PROBE_TIMEOUT`]; the two
|
||||
/// budgets are asymmetric on purpose — see [`probe_fresh`].)
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
/// Probe rounds before a tunnel is declared dead. A single lost mixnet packet
|
||||
/// mid-handshake should not condemn a whole tunnel, so an all-miss round is
|
||||
/// retried once (mirrors the DoT/DoH round loop). Only a tunnel that reaches
|
||||
/// NEITHER stable target across BOTH rounds is DEAD — this is what stops a
|
||||
/// Probe rounds before an ESTABLISHED tunnel is declared dead. A single lost
|
||||
/// mixnet packet mid-handshake should not condemn a whole tunnel, so an all-miss
|
||||
/// round is retried once (mirrors the DoT/DoH round loop). Only a tunnel that
|
||||
/// reaches NEITHER stable target across BOTH rounds is DEAD — this is what stops a
|
||||
/// healthy-but-unlucky tunnel from being thrown away and reselected forever.
|
||||
const PROBE_ROUNDS: usize = 2;
|
||||
|
||||
/// End-to-end exit-liveness probe: try to open a TCP connection THROUGH the tunnel
|
||||
/// to any of a few stable public addresses (raced, retried a round) and drop the
|
||||
/// winner immediately. Because TCP over the mixnet RETRANSMITS, a single lost
|
||||
/// datagram does not spuriously fail a healthy exit; racing several targets over
|
||||
/// two rounds additionally absorbs a momentarily slow single path — together they
|
||||
/// stop the false-DEAD reselect churn the old single-target probe caused. Proves
|
||||
/// the full path (mixnet → IPR exit → internet) and keeps the gateway/IPR session
|
||||
/// from idling out. Used by the fresh-tunnel gate and the watchdog keepalive.
|
||||
/// Per-target connect wait for the FAST GATE of a FRESH, just-built tunnel (before
|
||||
/// it is published). Tighter than the established [`PROBE_TIMEOUT`] because a
|
||||
/// healthy fresh probe connects FAST: across 15 cold-start trials the SUCCESSFUL
|
||||
/// exit probe completed in 465–1197ms (median 774ms), so 5s is >4x the measured
|
||||
/// worst case — ample headroom to never false-condemn a slow-but-healthy fresh
|
||||
/// exit (the build130 single-shot regression we must not reintroduce). The point
|
||||
/// of the asymmetry: a genuinely DEAD fresh exit (accepts the IPR handshake but
|
||||
/// delivers nothing) is now condemned in ~10s instead of the ~32s the doubled
|
||||
/// patient probe cost on this path, which dominated the cold-start latency tail.
|
||||
const FRESH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// Probe rounds for the fresh-tunnel gate. SAME 2-round retry as the established
|
||||
/// path: a single lost mixnet datagram mid-handshake still gets a second chance
|
||||
/// before the tunnel is condemned — the transient-loss protection the original
|
||||
/// trigger-happy single-shot probe lacked. Worst-case fresh-gate budget is
|
||||
/// therefore FRESH_PROBE_ROUNDS × FRESH_PROBE_TIMEOUT = 10s (vs the old ~32s).
|
||||
const FRESH_PROBE_ROUNDS: usize = 2;
|
||||
|
||||
/// PATIENT end-to-end liveness probe of an ESTABLISHED tunnel, on the generous
|
||||
/// [`PROBE_TIMEOUT`]/[`PROBE_ROUNDS`] budget (worst case ~16s). Used by the
|
||||
/// watchdog keepalive and the condemnation exit-DNS check — an exit already in
|
||||
/// service must never be false-condemned over a momentary hiccup. The FRESH,
|
||||
/// just-built-tunnel gate uses [`probe_fresh`] instead (a tighter budget). See
|
||||
/// [`probe_with_budget`] for the shared mechanics.
|
||||
pub async fn probe(tunnel: &Tunnel) -> bool {
|
||||
for round in 0..PROBE_ROUNDS {
|
||||
probe_with_budget(tunnel, PROBE_TIMEOUT, PROBE_ROUNDS).await
|
||||
}
|
||||
|
||||
/// FAST end-to-end liveness GATE for a FRESH, just-built tunnel, run BEFORE it is
|
||||
/// published, on the tighter [`FRESH_PROBE_TIMEOUT`]/[`FRESH_PROBE_ROUNDS`] budget
|
||||
/// (worst case ~10s vs the ~32s the doubled patient probe cost on this path). A
|
||||
/// fresh exit that accepts the IPR handshake yet delivers nothing (a DEAD EXIT) is
|
||||
/// condemned quickly instead of dominating the cold-start tail — WITHOUT
|
||||
/// reintroducing the false-condemn of a healthy exit (build130): the 5s per-target
|
||||
/// timeout is >4x the measured worst-case healthy fresh probe (1197ms) and the
|
||||
/// 2-round retry still absorbs a single lost datagram. See [`probe_with_budget`].
|
||||
pub async fn probe_fresh(tunnel: &Tunnel) -> bool {
|
||||
probe_with_budget(tunnel, FRESH_PROBE_TIMEOUT, FRESH_PROBE_ROUNDS).await
|
||||
}
|
||||
|
||||
/// Shared raced-targets liveness probe on an explicit per-target `timeout` /
|
||||
/// `rounds` budget: try to open a TCP connection THROUGH the tunnel to any of a few
|
||||
/// stable public addresses (raced, retried a round) and drop the winner
|
||||
/// immediately. Because TCP over the mixnet RETRANSMITS, a single lost datagram
|
||||
/// does not spuriously fail a healthy exit; racing several targets over multiple
|
||||
/// rounds additionally absorbs a momentarily slow single path — together they stop
|
||||
/// the false-DEAD reselect churn the old single-target probe caused. Proves the
|
||||
/// full path (mixnet → IPR exit → internet) and keeps the gateway/IPR session from
|
||||
/// idling out. Callers pick the budget: [`probe`] (patient, established tunnels)
|
||||
/// vs [`probe_fresh`] (fast, fresh-tunnel gate) — the racing + multi-round
|
||||
/// structure is identical, only the timeout/rounds differ.
|
||||
async fn probe_with_budget(tunnel: &Tunnel, timeout: Duration, rounds: usize) -> bool {
|
||||
for round in 0..rounds {
|
||||
let mut inflight = FuturesUnordered::new();
|
||||
for addr in PROBE_ADDRS {
|
||||
inflight.push(async move {
|
||||
matches!(
|
||||
tokio::time::timeout(PROBE_TIMEOUT, tunnel.tcp_connect(addr)).await,
|
||||
tokio::time::timeout(timeout, tunnel.tcp_connect(addr)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
});
|
||||
@@ -508,11 +555,11 @@ pub async fn probe(tunnel: &Tunnel) -> bool {
|
||||
}
|
||||
}
|
||||
debug!(
|
||||
"probe: no stable target reachable through tunnel (round {}/{PROBE_ROUNDS})",
|
||||
"probe: no stable target reachable through tunnel (round {}/{rounds})",
|
||||
round + 1
|
||||
);
|
||||
}
|
||||
debug!("probe: tunnel failed liveness — reached no stable target in {PROBE_ROUNDS} rounds");
|
||||
debug!("probe: tunnel failed liveness — reached no stable target in {rounds} rounds");
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
+134
-24
@@ -45,7 +45,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use log::{error, info, warn};
|
||||
use log::{debug, error, info, warn};
|
||||
use parking_lot::RwLock;
|
||||
use smolmix::{Recipient, Tunnel};
|
||||
|
||||
@@ -332,11 +332,18 @@ fn run_tunnel() {
|
||||
// publishing such a tunnel would blackhole every consumer
|
||||
// until the watchdog caught it minutes later. Re-select
|
||||
// immediately instead. (This is a CHEAP early signal; relay
|
||||
// reachability below is the authoritative one.)
|
||||
if !probe_fresh(&tunnel).await {
|
||||
// reachability below is the authoritative one.) Uses the FAST
|
||||
// fresh-gate budget (~10s worst case) — NOT the patient
|
||||
// established-tunnel probe (~32s doubled here before) — so a
|
||||
// dead fresh exit no longer dominates the cold-start tail; see
|
||||
// `dns::probe_fresh`.
|
||||
let probe_started = Instant::now();
|
||||
let alive = super::dns::probe_fresh(&tunnel).await;
|
||||
let probe_ms = probe_started.elapsed().as_millis();
|
||||
if !alive {
|
||||
warn!(
|
||||
"[timing] nym: DEAD EXIT — fresh {} tunnel failed liveness probe after {}ms \
|
||||
(attempt {attempt}); {}",
|
||||
"[timing] nym: DEAD EXIT — fresh {} tunnel failed liveness probe in {probe_ms}ms \
|
||||
({}ms total incl. build; attempt {attempt}); {}",
|
||||
choice.label(),
|
||||
started.elapsed().as_millis(),
|
||||
match choice {
|
||||
@@ -503,18 +510,6 @@ fn run_tunnel() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Two attempts of the (TCP, retransmitting) liveness probe before rejecting a
|
||||
/// fresh tunnel — one transient hiccup while the exit settles must not condemn
|
||||
/// an otherwise healthy exit.
|
||||
async fn probe_fresh(tunnel: &smolmix::Tunnel) -> bool {
|
||||
for _ in 0..2 {
|
||||
if super::dns::probe(tunnel).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Exit-liveness keepalive period and the consecutive probe failures that
|
||||
/// declare death (the probe is now a TCP connect through the tunnel, not UDP DNS).
|
||||
const KEEPALIVE_PERIOD: Duration = Duration::from_secs(60);
|
||||
@@ -840,14 +835,30 @@ async fn build_tunnel(
|
||||
cfg.traffic.message_sending_average_delay = Duration::from_millis(4);
|
||||
|
||||
// Mirror the mainnet env setup the SDK's own constructors run before connect.
|
||||
// Done ONCE here (not per-raced-client): `setup_env` writes process-wide env
|
||||
// vars and must not be raced across the two connect tasks on the cold path.
|
||||
nym_sdk::setup_env(None::<&std::path::Path>);
|
||||
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?;
|
||||
|
||||
// GATEWAY CONNECT. Two shapes, both on the identical anonymity `cfg` (`Copy`):
|
||||
// * WARM hint (`entry_gateway.is_some()`): reconnect to the KNOWN-good first
|
||||
// hop — a Specified gateway, ephemeral storage, no persisted keys. NO race:
|
||||
// we want that specific gateway.
|
||||
// * COLD / auto (`entry_gateway.is_none()`): the first hop is a RANDOM draw and
|
||||
// a dead draw blocks `connect_to_mixnet()` until `run_tunnel`'s 10s cap, with
|
||||
// consecutive dead draws stacking into a multi-second tail. Race TWO ephemeral
|
||||
// gateway connects and take the first up (see `connect_gateway_racing`). Only
|
||||
// the gateway handshake is doubled — the exit/IPR below is still built ONCE.
|
||||
let client = match entry_gateway {
|
||||
Some(gw) => {
|
||||
MixnetClientBuilder::new_ephemeral()
|
||||
.debug_config(cfg)
|
||||
.request_gateway(gw)
|
||||
.build()?
|
||||
.connect_to_mixnet()
|
||||
.await?
|
||||
}
|
||||
None => connect_gateway_racing(cfg).await?,
|
||||
};
|
||||
|
||||
// Capture the ENTRY GATEWAY actually used, from the client's own nym-address,
|
||||
// BEFORE `from_client` consumes the client.
|
||||
@@ -865,6 +876,105 @@ async fn build_tunnel(
|
||||
Ok((tunnel, entry_gw, ipr))
|
||||
}
|
||||
|
||||
/// Cold/auto gateway connect with a BOUNDED latency tail — the fix for the Nym
|
||||
/// cold-start "gateway lottery". Used ONLY on the auto path (no warm hint), where
|
||||
/// the entry gateway is a RANDOM draw: a dead draw blocks `connect_to_mixnet()`
|
||||
/// until `run_tunnel`'s 10s cap and consecutive dead draws stack into the tail.
|
||||
///
|
||||
/// Race EXACTLY TWO ephemeral `MixnetClient`s — IDENTICAL anonymity `cfg`,
|
||||
/// ephemeral keys, nothing persisted — through the gateway handshake and return
|
||||
/// the FIRST that connects. Only the gateway handshake is doubled; the caller
|
||||
/// builds the exit/IPR ONCE on the winner. Two (not more) bounds the Nym
|
||||
/// free-tier bandwidth burst.
|
||||
///
|
||||
/// The loser is REAPED so a CONNECTED client is never leaked: it is aborted (a
|
||||
/// still-pending connect just drops its half-built client) and, in a DETACHED task
|
||||
/// so the winner returns immediately, `disconnect()`ed IFF it had already
|
||||
/// connected. If BOTH draws fail, the error is returned so `run_tunnel`'s loop
|
||||
/// re-selects — the same contract as the single build.
|
||||
async fn connect_gateway_racing(
|
||||
cfg: nym_sdk::DebugConfig,
|
||||
) -> Result<nym_sdk::mixnet::MixnetClient, smolmix::SmolmixError> {
|
||||
use nym_sdk::mixnet::{MixnetClient, MixnetClientBuilder};
|
||||
|
||||
async fn connect_one(cfg: nym_sdk::DebugConfig) -> Result<MixnetClient, smolmix::SmolmixError> {
|
||||
Ok(MixnetClientBuilder::new_ephemeral()
|
||||
.debug_config(cfg)
|
||||
.build()?
|
||||
.connect_to_mixnet()
|
||||
.await?)
|
||||
}
|
||||
|
||||
// Spawn both so the loser can be aborted cleanly. `cfg` is `Copy`, so each task
|
||||
// gets the identical anonymity config.
|
||||
let race_started = Instant::now();
|
||||
let mut a = tokio::spawn(connect_one(cfg));
|
||||
let mut b = tokio::spawn(connect_one(cfg));
|
||||
debug!("[timing] nym: gateway race START — 2 ephemeral draws, first up wins");
|
||||
|
||||
// Whichever finishes first; keep `other` to reap (on a win) or fall back to (if
|
||||
// the first draw errored). `winner` tags WHICH draw finished first.
|
||||
let (first, other, winner) = tokio::select! {
|
||||
r = &mut a => (r, b, 'A'),
|
||||
r = &mut b => (r, a, 'B'),
|
||||
};
|
||||
// A JoinError (task panic) folds into an error so `other` still gets its turn.
|
||||
let first = first.unwrap_or_else(|e| {
|
||||
Err(smolmix::SmolmixError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("nym gateway connect task failed: {e}"),
|
||||
)))
|
||||
});
|
||||
|
||||
match first {
|
||||
// First to finish connected — it WINS. Reap the loser off the hot path.
|
||||
Ok(client) => {
|
||||
info!(
|
||||
"[timing] nym: gateway race WON by draw {winner} in {}ms; reaping loser off the hot path",
|
||||
race_started.elapsed().as_millis()
|
||||
);
|
||||
other.abort();
|
||||
tokio::spawn(async move {
|
||||
// If the loser connected before the abort landed, disconnect it so
|
||||
// no live gateway session leaks; a pending connect was just dropped.
|
||||
match other.await {
|
||||
Ok(Ok(loser)) => {
|
||||
debug!(
|
||||
"[timing] nym: gateway race loser had connected before abort — \
|
||||
disconnecting so no gateway session leaks"
|
||||
);
|
||||
loser.disconnect().await;
|
||||
}
|
||||
_ => debug!(
|
||||
"[timing] nym: gateway race loser still pending at reap — dropped \
|
||||
(no session to close)"
|
||||
),
|
||||
}
|
||||
});
|
||||
Ok(client)
|
||||
}
|
||||
// First draw failed — a lone client has no dead-draw tail, so just await the
|
||||
// survivor; if it fails too, surface an error and `run_tunnel` re-selects.
|
||||
Err(first_err) => match other.await {
|
||||
Ok(Ok(client)) => {
|
||||
info!(
|
||||
"[timing] nym: gateway race — draw {winner} errored, survivor connected in {}ms",
|
||||
race_started.elapsed().as_millis()
|
||||
);
|
||||
Ok(client)
|
||||
}
|
||||
Ok(Err(second_err)) => {
|
||||
warn!(
|
||||
"[timing] nym: both raced gateway connects failed \
|
||||
({first_err}; {second_err}); run_tunnel will re-select"
|
||||
);
|
||||
Err(second_err)
|
||||
}
|
||||
Err(_join) => Err(first_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user