1
0
forked from GRIN/grim

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:
2ro
2026-07-03 17:22:42 -04:00
parent e8bfe8d76f
commit d05e015f98
8 changed files with 244 additions and 55 deletions
Generated
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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}"
+4 -3
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 4651197ms (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
View File
@@ -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::*;