diff --git a/docs/explanation/defensive-measures.md b/docs/explanation/defensive-measures.md index 735e75c..1a7764a 100644 --- a/docs/explanation/defensive-measures.md +++ b/docs/explanation/defensive-measures.md @@ -31,17 +31,17 @@ operator configurable; other dependency hardening is pinned in the builder. These limits prevent individual connections from overwhelming the relay. The relay advertises the standard `max_subscriptions`, `max_limit`, `default_limit`, `max_message_length`, and `max_subid_length` NIP-11 fields. -rust-nostr 0.45 also enforces fixed ngit-grasp-selected defaults of 1,200 queries, -30 authentication events, and 6,000 WebSocket messages per minute; 20 filters +rust-nostr 0.45.3 supplies defaults of 1,200 query starts and 6,000 WebSocket +messages per minute. ngit-grasp additionally configures +30 authentication events per minute; 20 filters per REQ; 5 MiB subscription state (raised from rust-nostr's 1 MiB default so repository-scale persistent live filters remain admitted); 10 active negentropy sessions and 50,000 negentropy items per connection; a 5 MiB WebSocket message; and a 10-second handshake deadline. NIP-11 has no standard fields for most of those controls. -The query allowance is a temporary 10× override of rust-nostr's newly added -120/minute default because NIP-77 currently charges each SDK-managed `NEG-MSG` -continuation separately; it must be reviewed when upstream revises that -accounting. +NIP-77 continuations use a separate 1,200/minute allowance and remain subject +to the connection-wide message cap. The older SDK compatibility overrides +are no longer needed. ### Per-IP Connection Monitoring diff --git a/docs/explanation/sync-scaling-constraints.md b/docs/explanation/sync-scaling-constraints.md index 23f4c4c..5bb77e0 100644 --- a/docs/explanation/sync-scaling-constraints.md +++ b/docs/explanation/sync-scaling-constraints.md @@ -92,14 +92,16 @@ rust-nostr reports its configured ceiling. The learned ceiling never rises in the session and resets on reconnect. A one-filter refusal is not recoverable by regrouping and uses the ordinary long `filter_incompatible` policy pause. -### Our own embedded relay (nostr-sdk `LocalRelay`, 0.45.0) +### Our own embedded relay (nostr-sdk `LocalRelay`, 0.45.3) - `max_reqs` = 500, enforced for REQ only (`src/nostr/builder.rs`). -- Negentropy: **no concurrency limit at all** (upstream `TODO`), 60000-byte - frame limit per NEG message. +- Negentropy: ngit-grasp configures at most 10 retained sessions and 50,000 + retained items per connection, with a 60,000-byte protocol frame limit. + Continuations have a separate allowance from query starts. LMDB scans run + on blocking workers, so a scan does not occupy an async runtime worker. - 20 filters per REQ by default; no limit on tag values per filter. -- Query result limits (verified 2026-08-06 against the published - `nostr-sdk-0.45.0` crate source, `src/local_relay/local/inner.rs`): +- Query result limits (verified against the published `nostr-sdk-0.45.3` + crate source, `src/local_relay/local/inner.rs`): enforced **per filter, not per REQ**. A filter without a `limit` is given `default_filter_limit` (500); the effective limit is then clamped to `min(limit, max_filter_limit, max_query_results)` (defaults: no @@ -191,18 +193,13 @@ strfry and Ditto have no native limiter at all, deferring to deployment infrastructure. The full survey with citations is preserved in this file's history (commit `9723ff4`). -The 120-query allowance is a newly enabled rust-nostr 0.45 LocalRelay default, -not a floor established by that survey. It is unusually restrictive: none of -the other audited implementations enables an equivalent query-specific, -per-connection default. A finite limit is still useful as one layer of DoS -protection, but a small per-connection bucket is not sufficient protection by -itself because a hostile client can multiply connections; per-IP admission and -global resource bounds address that threat more directly. rust-nostr also -charges SDK-managed NIP-77 `NEG-MSG` continuation frames to this same bucket, -so one application-started reconciliation can consume multiple query tokens. -ngit-grasp temporarily overrides that default to 1,200 queries/minute while -retaining a finite per-connection backstop. Re-evaluate the 10× value after -upstream separates or otherwise revises NIP-77 continuation accounting. +rust-nostr 0.45.3 supplies the 1,200-query-start allowance that ngit-grasp +previously selected with a compatibility override. NIP-77 continuations use +an independent 1,200/minute allowance, while all frames still share the +6,000/minute message cap. A finite per-connection limit remains one DoS layer; +per-IP admission and global resource bounds address clients multiplying +connections. Outbound pacing remains necessary for peers with older SDKs or +stricter operator-selected limits. NIP-11 describes hard relay limitations, not rate-limit algorithms. The standard fields relevant here are `max_limit` and @@ -484,7 +481,7 @@ So concurrency is not a free scaling axis; it is the residual of the ledger: first rejects locally queued starts for the existing 65-second cooldown so their incomplete work can be re-derived in priority order, then learns a shared query-start interval: 600 ms initially (100 starts/minute, below the - rust-nostr 120/minute default), doubling on a later rate-limit episode up to + older rust-nostr 120/minute limit), doubling on a later rate-limit episode up to 10 seconds. Live, transient REQ, exact-ID fetch and NIP-77 round starts all pass through that reactive pacer in addition to background work retaining its proactive spacing. Relays that never report a query rate limit still diff --git a/docs/reference/relay-limits.md b/docs/reference/relay-limits.md index 2bd2530..5aa0f8a 100644 --- a/docs/reference/relay-limits.md +++ b/docs/reference/relay-limits.md @@ -13,9 +13,10 @@ production admission policy. | Results per filter | 500 | `NGIT_RELAY_FILTER_LIMIT` | `max_limit`, `default_limit` | | Serialized event size | 192 KiB | `NGIT_RELAY_MAX_EVENT_SIZE_BYTES` | No equivalent field | | Event writes per minute | 60 | Fixed | No standard field | -| Queries per minute | 1,200 | Fixed temporary override | No standard field | +| Query starts per minute | 1,200 | Upstream default | No standard field | +| NIP-77 continuations per minute | 1,200 | Upstream default | No standard field | | Authentication events per minute | 30 | Fixed | No standard field | -| WebSocket messages per minute | 6,000 | Fixed | No standard field | +| WebSocket messages per minute | 6,000 | Upstream default | No standard field | | WebSocket message size | 5 MiB | Fixed | `max_message_length` | | Handshake deadline | 10 seconds | Fixed | No standard field | | Subscription-ID length | 250 bytes | Fixed | `max_subid_length` | @@ -43,13 +44,11 @@ matches the maximum admitted WebSocket message, and provides about four times the observed working-set headroom. NIP-11 has no field for this cumulative byte limit, so clients cannot negotiate it. -The 1,200-query allowance temporarily overrides rust-nostr 0.45's newly -introduced 120/minute default. A finite per-connection bound remains one useful -DoS layer, but the upstream default is unusually restrictive for sync clients -and currently counts every SDK-managed NIP-77 `NEG-MSG` continuation as another -query. Review this 10× override after upstream changes that accounting; a small -per-connection quota is not a substitute for per-IP admission or global -resource bounds because clients can multiply connections. +The SDK now supplies the 1,200-query-start and 6,000-message allowances that +ngit-grasp previously overrode locally. NIP-77 `NEG-MSG` continuations use a +separate 1,200/minute allowance and still count against the connection-wide +message limit. These finite per-connection quotas do not replace per-IP +admission or global resource bounds because clients can multiply connections. ## Client adaptation diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 5b8e23f..7a40df6 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -36,24 +36,6 @@ use crate::private::PrivateAccess; use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks; use crate::sync::rejected_index::RejectedEventsIndex; -/// Connection-wide frame allowance layered above the operation-specific -/// write, query, and authentication quotas. -/// -/// rust-nostr's 300/minute default closed production browser clients during -/// ordinary bursty subscription churn. One hundred frames per second preserves a -/// bounded catch-all for malformed/non-operation traffic while leaving the -/// tighter operation quotas in charge of valid protocol work. -const CLIENT_MESSAGES_PER_MINUTE: u32 = 6_000; - -/// Temporary compatibility override for rust-nostr 0.45's newly introduced -/// 120-query-per-minute default. -/// -/// A finite per-connection bound remains useful as one DoS layer, but 120 is -/// too restrictive for legitimate sync and NIP-77 currently charges every -/// SDK-managed `NEG-MSG` continuation against the same allowance. Re-evaluate -/// this 10× value after upstream separates or otherwise revises that accounting. -const CLIENT_QUERIES_PER_MINUTE: u32 = 1_200; - /// Cumulative serialized REQ state retained for one client connection. /// /// Repository sync legitimately keeps multiple byte-budgeted live filters @@ -1145,9 +1127,7 @@ pub async fn create_relay( max_reqs: config.relay_max_subscriptions, notes_per_minute: 60, }) - .queries_per_minute(CLIENT_QUERIES_PER_MINUTE) .auth_events_per_minute(30) - .messages_per_minute(CLIENT_MESSAGES_PER_MINUTE) .max_websocket_message_size(5 * 1024 * 1024) .max_event_size(config.relay_max_event_size_bytes) .websocket_handshake_timeout(Duration::from_secs(10)) @@ -1160,11 +1140,11 @@ pub async fn create_relay( .max_query_results(config.relay_filter_limit) .default_filter_limit(config.relay_filter_limit); - // `LocalRelayBuilder` otherwise applies rust-nostr's own finite default. - // In ngit-grasp, an unset limit deliberately delegates admission control to - // the OS and surrounding infrastructure, as documented by every config - // surface. Explicit operator limits remain exact. - builder = builder.max_connections(effective_max_connections(config.max_connections)); + // The SDK defaults to unlimited established connections. Preserve an + // explicit operator limit when one is configured. + if let Some(limit) = config.max_connections { + builder = builder.max_connections(limit); + } let relay = builder.build(); @@ -1196,25 +1176,3 @@ pub async fn create_relay( }, }) } - -fn effective_max_connections(configured: Option) -> usize { - configured.unwrap_or(tokio::sync::Semaphore::MAX_PERMITS) -} - -#[cfg(test)] -mod connection_limit_tests { - use super::effective_max_connections; - - #[test] - fn unset_connection_limit_overrides_dependency_default() { - assert_eq!( - effective_max_connections(None), - tokio::sync::Semaphore::MAX_PERMITS - ); - } - - #[test] - fn configured_connection_limit_remains_exact() { - assert_eq!(effective_max_connections(Some(2)), 2); - } -} diff --git a/src/sync/relay_connection.rs b/src/sync/relay_connection.rs index f88a01b..33022b9 100644 --- a/src/sync/relay_connection.rs +++ b/src/sync/relay_connection.rs @@ -45,8 +45,8 @@ const SUBSCRIPTION_RESERVED_MARGIN: usize = 2; /// allowance is exhausted. /// /// NIP-11 cannot advertise query-rate windows. One start every 600 ms is 100 -/// starts/minute, leaving operational slack below rust-nostr 0.45's newly -/// introduced 120/minute LocalRelay default. This is deliberately reactive: +/// starts/minute, leaving operational slack below the 120/minute limit used +/// by older rust-nostr relays. This is deliberately reactive: /// proactively pacing non-urgent history needs request-class priority so live /// coverage is not delayed, and belongs in a separate scheduler change. const QUERY_PACING_INITIAL_INTERVAL: Duration = Duration::from_millis(600); diff --git a/tests/relay_message_rate.rs b/tests/relay_message_rate.rs index 3694237..a5795eb 100644 --- a/tests/relay_message_rate.rs +++ b/tests/relay_message_rate.rs @@ -82,3 +82,51 @@ async fn catch_all_limit_still_closes_excessive_frame_burst() { ); relay.stop().await; } + +#[tokio::test] +async fn sdk_query_allowance_accepts_more_than_120_starts() { + let relay = TestRelay::start().await; + tokio::time::timeout(Duration::from_secs(15), async { + let (mut stream, _) = tokio_tungstenite::connect_async(relay.url()).await.unwrap(); + for _ in 0..121 { + stream + .send(Message::Text( + r#"["REQ","probe",{"kinds":[1],"limit":0}]"#.into(), + )) + .await + .unwrap(); + let response = stream.next().await.unwrap().unwrap().into_text().unwrap(); + assert_eq!(response, r#"["EOSE","probe"]"#); + } + stream.close(None).await.unwrap(); + }) + .await + .expect("query burst exceeded deadline"); + relay.stop().await; +} + +#[tokio::test] +async fn sdk_default_allows_more_than_128_established_connections() { + let relay = TestRelay::start().await; + tokio::time::timeout(Duration::from_secs(30), async { + let mut connections = Vec::new(); + for _ in 0..129 { + let (mut stream, _) = tokio_tungstenite::connect_async(relay.url()).await.unwrap(); + stream + .send(Message::Text( + r#"["REQ","probe",{"kinds":[1],"limit":0}]"#.into(), + )) + .await + .unwrap(); + let response = stream.next().await.unwrap().unwrap().into_text().unwrap(); + assert_eq!(response, r#"["EOSE","probe"]"#); + connections.push(stream); + } + for mut stream in connections { + stream.close(None).await.unwrap(); + } + }) + .await + .expect("connection admission exceeded deadline"); + relay.stop().await; +}