Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9493b499 | ||
|
|
75638c72b7 | ||
|
|
38eb721ae8 |
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.0] - 2026-08-27
|
||||
|
||||
## [0.0.3] - 2026-08-19
|
||||
|
||||
## [0.0.2] - 2026-08-17
|
||||
|
||||
Generated
+16
-12
@@ -380,12 +380,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-signer"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
@@ -868,7 +868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "integration-tests"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
@@ -910,7 +910,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "keypair-generator"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
]
|
||||
@@ -1041,7 +1041,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-core"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -1063,7 +1063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-core-umbrella"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
@@ -1074,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-nips"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"block-modes",
|
||||
@@ -1096,7 +1096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-relay"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"nostr-core",
|
||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-services"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"nostr-core",
|
||||
"nostr-relay",
|
||||
@@ -1129,11 +1129,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-signer"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"hex",
|
||||
"libc",
|
||||
"nostr-core",
|
||||
"rand",
|
||||
"serde",
|
||||
@@ -1448,7 +1449,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1798,7 +1799,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1907,7 +1908,9 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
@@ -2019,6 +2022,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.3"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
@@ -36,7 +36,7 @@ serde_json = "1"
|
||||
|
||||
# Networking
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
tungstenite = "0.24"
|
||||
reqwest = { version = "0.12", features = ["json", "native-tls"] }
|
||||
url = "2"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A Rust implementation of the NOSTR protocol library, ported from the C `nostr_core_lib` project.
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#building)
|
||||
|
||||
@@ -104,7 +104,7 @@ nostr_core_lib_rust/
|
||||
├── core/ # Core types, errors, crypto, utilities
|
||||
├── relay/ # WebSocket, HTTP, relay pool
|
||||
├── nips/ # All NIP implementations
|
||||
├── signer/ # Signer trait, local + nsigner remote
|
||||
├── signer/ # Signer trait, local + signer remote
|
||||
├── services/ # Request validator, Blossom, Cashu
|
||||
├── nostr-core/ # Umbrella re-export crate
|
||||
├── examples/ # Example programs
|
||||
@@ -156,7 +156,7 @@ nostr-core = { git = "ssh://git@laantungir.net:2222/laantungir/nostr_core_lib_ru
|
||||
| `nostr-core` | 34 | Types, errors, crypto, utilities |
|
||||
| `nostr-nips` | 68 | All 14 NIP implementations |
|
||||
| `nostr-relay` | 8 | WebSocket, HTTP, relay pool |
|
||||
| `nostr-signer` | 23 | Signer trait, local + nsigner |
|
||||
| `nostr-signer` | 23 | Signer trait, local + signer |
|
||||
| `nostr-services` | 31 | Validator, Blossom, Cashu |
|
||||
| `integration-tests` | 38 | Ported from C test suite |
|
||||
| **Total** | **202** | |
|
||||
|
||||
+9
-9
@@ -191,11 +191,11 @@ pub enum NostrError {
|
||||
#[error("NIP-03: invalid event id")]
|
||||
Nip03InvalidEventId,
|
||||
|
||||
// ── Nsigner ─────────────────────────────────────────────────────
|
||||
#[error("nsigner: policy denied")]
|
||||
NsignerPolicyDenied,
|
||||
#[error("nsigner: index not allowed")]
|
||||
NsignerIndexNotAllowed,
|
||||
// ── Signer ──────────────────────────────────────────────────────
|
||||
#[error("signer: policy denied")]
|
||||
SignerPolicyDenied,
|
||||
#[error("signer: index not allowed")]
|
||||
SignerIndexNotAllowed,
|
||||
|
||||
// ── Internal / catch-all ────────────────────────────────────────
|
||||
#[error("unknown error code: {0}")]
|
||||
@@ -283,8 +283,8 @@ impl From<i32> for NostrError {
|
||||
-425 => NostrError::CashuProofsSpent,
|
||||
-426 => NostrError::CashuCryptoFailed,
|
||||
-427 => NostrError::CashuInvalidKeyset,
|
||||
-2001 => NostrError::NsignerPolicyDenied,
|
||||
-2002 => NostrError::NsignerIndexNotAllowed,
|
||||
-2001 => NostrError::SignerPolicyDenied,
|
||||
-2002 => NostrError::SignerIndexNotAllowed,
|
||||
-6 => NostrError::Nip03InvalidOtsFormat,
|
||||
-7 => NostrError::Nip03InvalidEventId,
|
||||
other => NostrError::Unknown(other),
|
||||
@@ -375,8 +375,8 @@ impl From<NostrError> for i32 {
|
||||
NostrError::CashuInvalidKeyset => -427,
|
||||
NostrError::Nip03InvalidOtsFormat => -6,
|
||||
NostrError::Nip03InvalidEventId => -7,
|
||||
NostrError::NsignerPolicyDenied => -2001,
|
||||
NostrError::NsignerIndexNotAllowed => -2002,
|
||||
NostrError::SignerPolicyDenied => -2001,
|
||||
NostrError::SignerIndexNotAllowed => -2002,
|
||||
NostrError::Unknown(code) => code,
|
||||
}
|
||||
}
|
||||
|
||||
+335
-29
@@ -109,6 +109,32 @@ struct RelayEntry {
|
||||
reconnect_attempts: u32,
|
||||
last_connection_error: Option<String>,
|
||||
last_publish_error: Option<String>,
|
||||
/// Earliest time a reconnect attempt is allowed (backoff gate).
|
||||
next_reconnect_at: Option<Instant>,
|
||||
/// When the current connection was established (for stability reset).
|
||||
connected_since: Option<Instant>,
|
||||
/// When the last ping was sent (health monitoring).
|
||||
last_ping_sent: Option<Instant>,
|
||||
/// Whether a ping is awaiting its pong.
|
||||
ping_pending: bool,
|
||||
}
|
||||
|
||||
impl RelayEntry {
|
||||
fn new(url: &str) -> Self {
|
||||
RelayEntry {
|
||||
client: NostrWsClient::new(url),
|
||||
url: url.to_string(),
|
||||
status: RelayStatus::Disconnected,
|
||||
stats: RelayStats::default(),
|
||||
reconnect_attempts: 0,
|
||||
last_connection_error: None,
|
||||
last_publish_error: None,
|
||||
next_reconnect_at: None,
|
||||
connected_since: None,
|
||||
last_ping_sent: None,
|
||||
ping_pending: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subscription ────────────────────────────────────────────────────────────
|
||||
@@ -257,15 +283,7 @@ impl RelayPool {
|
||||
if relays.iter().any(|r| r.url == url) {
|
||||
return Ok(()); // Already added
|
||||
}
|
||||
relays.push(RelayEntry {
|
||||
client: NostrWsClient::new(url),
|
||||
url: url.to_string(),
|
||||
status: RelayStatus::Disconnected,
|
||||
stats: RelayStats::default(),
|
||||
reconnect_attempts: 0,
|
||||
last_connection_error: None,
|
||||
last_publish_error: None,
|
||||
});
|
||||
relays.push(RelayEntry::new(url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -283,12 +301,34 @@ impl RelayPool {
|
||||
}
|
||||
|
||||
/// Connect to all relays in the pool.
|
||||
///
|
||||
/// Each connection attempt is bounded by a timeout so a single
|
||||
/// unresponsive relay cannot block the whole pool.
|
||||
pub async fn connect_all(&self) {
|
||||
self.connect_all_with_timeout(10_000).await;
|
||||
}
|
||||
|
||||
/// Connect to all relays, bounding each attempt by `timeout_ms`.
|
||||
pub async fn connect_all_with_timeout(&self, timeout_ms: u64) {
|
||||
let relays = self.relays.read().await;
|
||||
for entry in relays.iter() {
|
||||
if entry.status == RelayStatus::Disconnected {
|
||||
if let Err(e) = entry.client.connect().await {
|
||||
warn!("Failed to connect to {}: {:?}", entry.url, e);
|
||||
let url = entry.url.clone();
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(timeout_ms),
|
||||
entry.client.connect(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
warn!("Failed to connect to {}: {:?}", url, e);
|
||||
eprintln!("[relay] connect failed: {}", url);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Connect to {} timed out", url);
|
||||
eprintln!("[relay] connect timed out: {}", url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,6 +370,23 @@ impl RelayPool {
|
||||
relays.iter().map(|r| (r.url.clone(), r.status.clone())).collect()
|
||||
}
|
||||
|
||||
/// Get the URLs of relays whose WebSocket is currently connected.
|
||||
///
|
||||
/// Useful for querying only reachable relays — a subscription in
|
||||
/// `EoseResultMode::FullSet` waits for EOSE from every relay it was
|
||||
/// given, so including a relay that failed to connect would stall the
|
||||
/// query until its timeout.
|
||||
pub async fn connected_relay_urls(&self) -> Vec<String> {
|
||||
let relays = self.relays.read().await;
|
||||
let mut urls = Vec::new();
|
||||
for entry in relays.iter() {
|
||||
if entry.client.state().await == WsState::Connected {
|
||||
urls.push(entry.url.clone());
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
/// Set NIP-42 authentication credentials.
|
||||
pub async fn set_auth(&self, private_key: &SecretKey, enable: bool) {
|
||||
let mut key = self.auth_key.lock().await;
|
||||
@@ -378,8 +435,11 @@ impl RelayPool {
|
||||
let relays = self.relays.read().await;
|
||||
for url in relay_urls {
|
||||
if let Some(entry) = relays.iter().find(|r| r.url == *url) {
|
||||
// Connect if not connected
|
||||
if entry.client.state().await == WsState::Disconnected {
|
||||
// Connect if not connected (covers Disconnected AND Error —
|
||||
// a relay that died mid-session must be reconnectable here,
|
||||
// not just one that was never connected)
|
||||
let state = entry.client.state().await;
|
||||
if state != WsState::Connected {
|
||||
if let Err(e) = entry.client.connect().await {
|
||||
warn!("Failed to connect to {} for subscription: {:?}", url, e);
|
||||
continue;
|
||||
@@ -421,27 +481,173 @@ impl RelayPool {
|
||||
// ── Event Loop ──────────────────────────────────────────────────────
|
||||
|
||||
/// Run the event loop to process incoming messages from all relays.
|
||||
/// This should be called in a background task.
|
||||
///
|
||||
/// This should be called in a background task. Each iteration:
|
||||
///
|
||||
/// 1. Attempts reconnection of any non-connected relay whose backoff
|
||||
/// window has elapsed (auto-reconnect with exponential backoff).
|
||||
/// 2. Sends health-check pings on the configured interval and detects
|
||||
/// pong timeouts / dead transports.
|
||||
/// 3. Syncs the pool-level `RelayStatus` from the live ws state so
|
||||
/// `list_relays()` / `get_relay_status()` report reality.
|
||||
/// 4. Receives and dispatches incoming messages from connected relays.
|
||||
pub async fn run(&self, timeout_ms: u64) {
|
||||
loop {
|
||||
let relays = self.relays.read().await;
|
||||
let mut has_activity = false;
|
||||
|
||||
for entry in relays.iter() {
|
||||
if entry.client.state().await != WsState::Connected {
|
||||
continue;
|
||||
}
|
||||
// Phase 1: reconnect + health check + status sync (needs write access)
|
||||
{
|
||||
let mut relays = self.relays.write().await;
|
||||
for entry in relays.iter_mut() {
|
||||
let ws_state = entry.client.state().await;
|
||||
|
||||
match entry.client.receive_text(timeout_ms).await {
|
||||
Ok(Some(text)) => {
|
||||
has_activity = true;
|
||||
self.process_message(&entry.url, &text).await;
|
||||
// Sync pool status from live ws state
|
||||
let new_status = match &ws_state {
|
||||
WsState::Connected => RelayStatus::Connected,
|
||||
WsState::Connecting => RelayStatus::Connecting,
|
||||
WsState::Disconnected => RelayStatus::Disconnected,
|
||||
WsState::Closing => RelayStatus::Disconnected,
|
||||
WsState::Error(e) => RelayStatus::Error(e.clone()),
|
||||
};
|
||||
if entry.status != new_status {
|
||||
debug!("relay {} status: {:?} -> {:?}", entry.url, entry.status, new_status);
|
||||
entry.status = new_status;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Timeout or connection closed
|
||||
|
||||
if ws_state == WsState::Connected {
|
||||
// Track connection stability: reset backoff after the
|
||||
// connection has been up long enough.
|
||||
if let Some(since) = entry.connected_since {
|
||||
if since.elapsed().as_secs()
|
||||
>= self.reconnect_config.reconnect_reset_stability_secs
|
||||
&& entry.reconnect_attempts > 0
|
||||
{
|
||||
entry.reconnect_attempts = 0;
|
||||
entry.next_reconnect_at = None;
|
||||
}
|
||||
} else {
|
||||
entry.connected_since = Some(Instant::now());
|
||||
}
|
||||
|
||||
// Health monitoring: send ping on interval
|
||||
if self.reconnect_config.ping_interval_seconds > 0 {
|
||||
let should_ping = match entry.last_ping_sent {
|
||||
None => true,
|
||||
Some(t) => {
|
||||
t.elapsed().as_secs()
|
||||
>= self.reconnect_config.ping_interval_seconds
|
||||
}
|
||||
};
|
||||
if should_ping && !entry.ping_pending {
|
||||
match entry.client.ping().await {
|
||||
Ok(()) => {
|
||||
entry.last_ping_sent = Some(Instant::now());
|
||||
entry.ping_pending = true;
|
||||
}
|
||||
Err(_) => {
|
||||
// Ping send failed: transport is dead.
|
||||
// ws.rs already set the client state to
|
||||
// Error; force-close so the reconnect
|
||||
// path picks it up next iteration.
|
||||
entry.client.close().await.ok();
|
||||
entry.connected_since = None;
|
||||
entry.ping_pending = false;
|
||||
entry.stats.connection_failures += 1;
|
||||
warn!("relay {} ping failed; marked for reconnect", entry.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pong timeout: connection is dead
|
||||
if entry.ping_pending {
|
||||
if let Some(t) = entry.last_ping_sent {
|
||||
if t.elapsed().as_secs()
|
||||
> self.reconnect_config.pong_timeout_seconds
|
||||
{
|
||||
warn!("relay {} pong timeout; reconnecting", entry.url);
|
||||
entry.client.close().await.ok();
|
||||
entry.connected_since = None;
|
||||
entry.ping_pending = false;
|
||||
entry.stats.connection_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not connected: reset per-connection bookkeeping
|
||||
entry.connected_since = None;
|
||||
entry.ping_pending = false;
|
||||
|
||||
// Auto-reconnect with backoff
|
||||
if self.reconnect_config.enable_auto_reconnect
|
||||
&& entry.reconnect_attempts
|
||||
< self.reconnect_config.max_reconnect_attempts
|
||||
{
|
||||
let due = match entry.next_reconnect_at {
|
||||
None => true,
|
||||
Some(t) => Instant::now() >= t,
|
||||
};
|
||||
if due {
|
||||
entry.reconnect_attempts += 1;
|
||||
entry.stats.connection_attempts += 1;
|
||||
debug!(
|
||||
"relay {} reconnect attempt {}/{}",
|
||||
entry.url,
|
||||
entry.reconnect_attempts,
|
||||
self.reconnect_config.max_reconnect_attempts
|
||||
);
|
||||
match entry.client.connect().await {
|
||||
Ok(()) => {
|
||||
entry.connected_since = Some(Instant::now());
|
||||
entry.last_ping_sent = None;
|
||||
entry.ping_pending = false;
|
||||
entry.last_connection_error = None;
|
||||
info!("relay {} reconnected", entry.url);
|
||||
// Keep reconnect_attempts until the
|
||||
// connection proves stable (reset above).
|
||||
let delay_ms = self.reconnect_delay_ms(entry.reconnect_attempts);
|
||||
entry.next_reconnect_at =
|
||||
Some(Instant::now() + Duration::from_millis(delay_ms));
|
||||
}
|
||||
Err(e) => {
|
||||
entry.last_connection_error =
|
||||
Some(format!("{:?}", e));
|
||||
let delay_ms = self.reconnect_delay_ms(entry.reconnect_attempts);
|
||||
entry.next_reconnect_at =
|
||||
Some(Instant::now() + Duration::from_millis(delay_ms));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Error receiving
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: receive and dispatch messages (needs write access for
|
||||
// ping_pending bookkeeping on inbound traffic)
|
||||
{
|
||||
let mut relays = self.relays.write().await;
|
||||
for entry in relays.iter_mut() {
|
||||
if entry.client.state().await != WsState::Connected {
|
||||
continue;
|
||||
}
|
||||
|
||||
match entry.client.receive_text(timeout_ms).await {
|
||||
Ok(Some(text)) => {
|
||||
has_activity = true;
|
||||
// Any inbound traffic proves liveness; the
|
||||
// ping_pending flag is reset by the Phase-1
|
||||
// writer pass on the next iteration.
|
||||
self.process_message(&entry.url, &text).await;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Timeout or connection closed — ws.rs updates
|
||||
// state on clean close; nothing to do here.
|
||||
}
|
||||
Err(_) => {
|
||||
// Receive error — ws.rs set state to Error;
|
||||
// the reconnect path picks it up next iteration.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,6 +658,18 @@ impl RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the reconnect backoff delay (ms) for the given attempt number.
|
||||
fn reconnect_delay_ms(&self, attempt: u32) -> u64 {
|
||||
let mut delay = self.reconnect_config.initial_reconnect_delay_ms;
|
||||
for _ in 1..attempt {
|
||||
delay = (delay as f64 * self.reconnect_config.reconnect_backoff_multiplier) as u64;
|
||||
if delay >= self.reconnect_config.max_reconnect_delay_ms {
|
||||
return self.reconnect_config.max_reconnect_delay_ms;
|
||||
}
|
||||
}
|
||||
delay.min(self.reconnect_config.max_reconnect_delay_ms)
|
||||
}
|
||||
|
||||
/// Process a received relay message.
|
||||
async fn process_message(&self, relay_url: &str, text: &str) {
|
||||
// Parse the JSON array
|
||||
@@ -737,8 +955,10 @@ impl RelayPool {
|
||||
let relays = self.relays.read().await;
|
||||
for url in &relay_urls_copy {
|
||||
if let Some(entry) = relays.iter().find(|r| r.url == *url) {
|
||||
// Connect if not connected
|
||||
if entry.client.state().await == WsState::Disconnected {
|
||||
// Connect if not connected (covers Disconnected AND Error —
|
||||
// a relay whose transport died must be reconnectable here)
|
||||
let state = entry.client.state().await;
|
||||
if state != WsState::Connected {
|
||||
if let Err(e) = entry.client.connect().await {
|
||||
warn!("Failed to connect to {} for publish: {:?}", url, e);
|
||||
continue;
|
||||
@@ -840,4 +1060,90 @@ mod tests {
|
||||
|
||||
assert!(sub.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reconnect_backoff_calculation() {
|
||||
let pool = RelayPool::new(None);
|
||||
|
||||
// Default config: initial 1000ms, multiplier 2.0, max 60_000ms
|
||||
assert_eq!(pool.reconnect_delay_ms(1), 1000);
|
||||
assert_eq!(pool.reconnect_delay_ms(2), 2000);
|
||||
assert_eq!(pool.reconnect_delay_ms(3), 4000);
|
||||
assert_eq!(pool.reconnect_delay_ms(4), 8000);
|
||||
assert_eq!(pool.reconnect_delay_ms(5), 16000);
|
||||
assert_eq!(pool.reconnect_delay_ms(6), 32000);
|
||||
// Clamped at max
|
||||
assert_eq!(pool.reconnect_delay_ms(7), 60000);
|
||||
assert_eq!(pool.reconnect_delay_ms(20), 60000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reconnect_backoff_custom_config() {
|
||||
let pool = RelayPool::new(Some(ReconnectConfig {
|
||||
enable_auto_reconnect: true,
|
||||
max_reconnect_attempts: 5,
|
||||
initial_reconnect_delay_ms: 500,
|
||||
max_reconnect_delay_ms: 5_000,
|
||||
reconnect_backoff_multiplier: 3.0,
|
||||
reconnect_reset_stability_secs: 30,
|
||||
ping_interval_seconds: 59,
|
||||
pong_timeout_seconds: 10,
|
||||
}));
|
||||
|
||||
assert_eq!(pool.reconnect_delay_ms(1), 500);
|
||||
assert_eq!(pool.reconnect_delay_ms(2), 1500);
|
||||
assert_eq!(pool.reconnect_delay_ms(3), 4500);
|
||||
// 13500 would exceed max 5000 → clamped
|
||||
assert_eq!(pool.reconnect_delay_ms(4), 5000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_status_initially_disconnected() {
|
||||
// The pool-level RelayStatus must start Disconnected and reflect
|
||||
// the live ws state after run()'s sync phase — not a stale
|
||||
// snapshot from add_relay time.
|
||||
let pool = RelayPool::new(None);
|
||||
pool.add_relay("wss://relay.damus.io").await.unwrap();
|
||||
|
||||
let status = pool.get_relay_status("wss://relay.damus.io").await;
|
||||
assert_eq!(status, Some(RelayStatus::Disconnected));
|
||||
|
||||
let relays = pool.relays.read().await;
|
||||
let entry = relays.iter().next().unwrap();
|
||||
let ws_state = entry.client.state().await;
|
||||
drop(relays);
|
||||
|
||||
assert_eq!(ws_state, WsState::Disconnected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connect_failure_sets_error_state() {
|
||||
// Connecting to an unreachable relay must leave the ws client in
|
||||
// Error state (not Connected), so the reconnect path can retry.
|
||||
let pool = RelayPool::new(None);
|
||||
pool.add_relay("wss://invalid.relay.example.invalid").await.unwrap();
|
||||
|
||||
let result = pool.connect_relay("wss://invalid.relay.example.invalid").await;
|
||||
assert!(result.is_err());
|
||||
|
||||
let relays = pool.relays.read().await;
|
||||
let entry = relays.iter().next().unwrap();
|
||||
let ws_state = entry.client.state().await;
|
||||
drop(relays);
|
||||
|
||||
assert!(matches!(ws_state, WsState::Error(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_relay_entry_defaults() {
|
||||
// New entries must start with clean reconnect bookkeeping so the
|
||||
// first reconnect attempt is immediate (no backoff gate).
|
||||
let entry = RelayEntry::new("wss://relay.example.com");
|
||||
assert_eq!(entry.reconnect_attempts, 0);
|
||||
assert!(entry.next_reconnect_at.is_none());
|
||||
assert!(entry.connected_since.is_none());
|
||||
assert!(!entry.ping_pending);
|
||||
assert!(entry.last_ping_sent.is_none());
|
||||
assert_eq!(entry.status, RelayStatus::Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-14
@@ -100,16 +100,27 @@ impl NostrWsClient {
|
||||
}
|
||||
|
||||
/// Send a text message to the relay.
|
||||
///
|
||||
/// On send failure the connection state is set to `Error` so callers
|
||||
/// (and the pool's reconnect logic) can detect a dead transport.
|
||||
/// Without this, a relay that dropped the TCP connection would keep
|
||||
/// reporting `Connected` and every publish would silently fail.
|
||||
pub async fn send_text(&self, message: &str) -> NostrResult<()> {
|
||||
let mut stream_guard = self.stream.lock().await;
|
||||
let stream = stream_guard
|
||||
.as_mut()
|
||||
.ok_or(NostrError::NetworkFailed)?;
|
||||
let stream = match stream_guard.as_mut() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let mut state = self.state.lock().await;
|
||||
*state = WsState::Error("send on disconnected stream".to_string());
|
||||
return Err(NostrError::NetworkFailed);
|
||||
}
|
||||
};
|
||||
|
||||
stream
|
||||
.send(Message::Text(message.to_string()))
|
||||
.await
|
||||
.map_err(|_| NostrError::NetworkFailed)?;
|
||||
if let Err(e) = stream.send(Message::Text(message.to_string())).await {
|
||||
let mut state = self.state.lock().await;
|
||||
*state = WsState::Error(format!("send error: {}", e));
|
||||
return Err(NostrError::NetworkFailed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -225,16 +236,25 @@ impl NostrWsClient {
|
||||
}
|
||||
|
||||
/// Send a ping frame to keep the connection alive.
|
||||
///
|
||||
/// On failure the connection state is set to `Error` so the pool's
|
||||
/// health check can detect a dead transport and trigger reconnect.
|
||||
pub async fn ping(&self) -> NostrResult<()> {
|
||||
let mut stream_guard = self.stream.lock().await;
|
||||
let stream = stream_guard
|
||||
.as_mut()
|
||||
.ok_or(NostrError::NetworkFailed)?;
|
||||
let stream = match stream_guard.as_mut() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let mut state = self.state.lock().await;
|
||||
*state = WsState::Error("ping on disconnected stream".to_string());
|
||||
return Err(NostrError::NetworkFailed);
|
||||
}
|
||||
};
|
||||
|
||||
stream
|
||||
.send(Message::Ping(vec![]))
|
||||
.await
|
||||
.map_err(|_| NostrError::NetworkFailed)?;
|
||||
if let Err(e) = stream.send(Message::Ping(vec![])).await {
|
||||
let mut state = self.state.lock().await;
|
||||
*state = WsState::Error(format!("ping error: {}", e));
|
||||
return Err(NostrError::NetworkFailed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,3 +17,4 @@ sha2.workspace = true
|
||||
hex.workspace = true
|
||||
rand.workspace = true
|
||||
serialport.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
//! Signer abstraction: local and remote (nsigner) implementations.
|
||||
//! Signer abstraction: local and remote (signer) implementations.
|
||||
|
||||
pub mod traits;
|
||||
pub mod local;
|
||||
pub mod nsigner;
|
||||
pub mod signer;
|
||||
|
||||
pub use traits::NostrSigner;
|
||||
pub use local::LocalSigner;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
//! NostrSigner trait — abstract interface for signing operations.
|
||||
//!
|
||||
//! Both local (in-memory private key) and remote (nsigner) signers
|
||||
//! Both local (in-memory private key) and remote (signer) signers
|
||||
//! implement this trait, matching the C `nostr_signer_t` abstraction.
|
||||
|
||||
use nostr_core::types::{Event, PublicKey};
|
||||
|
||||
Reference in New Issue
Block a user