From d05e015f98b4927aea26f4613ae4fbd77d21dacd Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:22:42 -0400 Subject: [PATCH] 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. --- Cargo.lock | 2 + Cargo.toml | 8 +- linux/build_release.sh | 14 ++- scripts/toolchain.sh | 7 +- src/gui/views/wallets/content.rs | 2 +- src/nostr/client.rs | 27 +++++- src/nym/dns.rs | 81 ++++++++++++---- src/nym/nymproc.rs | 158 ++++++++++++++++++++++++++----- 8 files changed, 244 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04878439..e030e5ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 3c2cd638..00a557e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/linux/build_release.sh b/linux/build_release.sh index bf9437c8..42a66bbf 100755 --- a/linux/build_release.sh +++ b/linux/build_release.sh @@ -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-. +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}" diff --git a/scripts/toolchain.sh b/scripts/toolchain.sh index d3fe6403..2a95e29a 100755 --- a/scripts/toolchain.sh +++ b/scripts/toolchain.sh @@ -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 diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index addc3f8e..f57c2d74 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -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); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 3a3a7781..41380022 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -938,6 +938,22 @@ async fn run_service(svc: Arc, 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, 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), } } diff --git a/src/nym/dns.rs b/src/nym/dns.rs index fd3e5825..26bcafcb 100644 --- a/src/nym/dns.rs +++ b/src/nym/dns.rs @@ -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 } diff --git a/src/nym/nymproc.rs b/src/nym/nymproc.rs index 7ae2e777..c8469c40 100644 --- a/src/nym/nymproc.rs +++ b/src/nym/nymproc.rs @@ -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 { + use nym_sdk::mixnet::{MixnetClient, MixnetClientBuilder}; + + async fn connect_one(cfg: nym_sdk::DebugConfig) -> Result { + 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::*;