Compare commits

...
Author SHA1 Message Date
Vitor PamplonaandGitHub fdc68e801a Merge pull request #3849 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-03 00:40:58 -04:00
vitorpamplonaandgithub-actions[bot] 30742ab352 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-03 04:35:55 +00:00
Vitor PamplonaandGitHub 1622bd7109 Merge pull request #3850 from vitorpamplona/claude/fetchallpages-timeoutms-d30cl1
Standardize timeout semantics to idle windows across all accessory APIs
2026-08-03 00:33:06 -04:00
Claude 64073f8fa9 perf: drop per-event iterator alloc; harden count/fetchFirst drain loops
Audit follow-up on the timeout work.

- fetchAllPages matched each event with `for ((i, f) in activeFilters)`.
  That destructuring form allocates an Iterator on every event, on the
  relay's reader thread, for the whole download -- millions of short-lived
  objects in a bulk walk. Switched to an indexed loop, which is why quartz
  uses the fast* operators elsewhere in hot event paths (those only cover
  Array, so a List needs the index form).

- count() and fetchFirst() drain their channels in an inner loop that only
  suspends when the channel is empty, so a backlog was consumed with no
  cancellation check: neither the idle window expiring nor the caller
  giving up could interrupt it mid-drain. Added an explicit ensureActive(),
  matching the check fetchAllPages already does per page.

- count() could also lose a result that arrived after the last window
  closed but before unsubscribe, understating the returned map. Added the
  post-loop tryReceive drain that fetchAllWithHooks and fetchFirst have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 04:28:18 +00:00
Claude 3ef61bafcb refactor: rename accessory timeoutMs to idleTimeoutMs
Every wait in the accessories package is an idle window measured from the
relay's most recent progress, so the parameter now says so. The name is
the contract: a caller reading timeoutMs reasonably expects a deadline,
which is exactly the misreading that made fetchAllPages' hard per-page
cap look correct for so long.

Renamed across the public surface -- fetchAll (7 overloads),
fetchAllWithHooks, fetchAllPages (2), fetchAllPagesFromPool,
fetchAllPagesFromPoolWithHooks, fetchFirst, count (2), countMerged -- plus
the quartz wrappers whose own parameter is a pure pass-through of that
window (KeyPackageFetcher, RecipientRelayFetcher, FollowerCrawler.Config)
and every call site across commons, cli, desktopApp and amethyst.

Deliberately NOT renamed, because these are genuine wall-clock bounds and
the differing name is the tell:
  - publishAndConfirm's timeoutInSeconds -- one fixed window to collect
    the OKs, a bounded confirmation round-trip rather than a stream.
  - GrapeRankCrawler.Config.timeoutMs -- a hard per-drain gate
    (withTimeoutOrNull(config.timeoutMs)) that also drives parking.
  - Context.awaitReply's timeoutMs, Context.syncIncoming, and the
    non-accessory app-layer helpers (RelayProber, FeedMetadataCoordinator,
    RelayAuthPromptBus).

This is a source-breaking change for named-argument callers of quartz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 04:16:31 +00:00
Claude ba35a87a7c refactor: drop the ceiling params; bound waits by progress instead
Follow-up audit on the timeout normalization. Three findings.

1. The maxTotalMs I added to fetchFirst and multi-relay count was the
   wrong shape twice over. A hard wall-clock bound already composes at the
   call site -- withTimeoutOrNull(ms) { fetchFirst(...) } -- so putting it
   in the signature duplicates what the caller has for free. And it was
   papering over the real defect: repeat chatter from a relay already
   accounted for (a CLOSED/reconnect loop, a duplicate COUNT) was treated
   as activity and restarted the idle window, so a flapping relay could
   hold the call open indefinitely. Both now reset the window only on
   genuine progress -- an event, or the first terminal signal from a relay
   still being waited on -- which is the rule the negentropy watchdog
   already applies to NOTICE/CLOSED chatter, and which makes both calls
   self-bounding at one window per relay. Ceiling params removed; the
   overflow guard they needed goes with them.

2. fetchFirst could drop a match: an event landing after the last terminal
   signal but before unsubscribe was left unread in the channel and the
   fetch reported nothing found. Added the post-loop drain that
   fetchAllWithHooks already does. Covered by a test.

3. fetchAllPages published its per-page counters across threads without a
   barrier on the idle path. received/delivered/pageMinTs/idsAtPageMin are
   written on the relay reader thread and read by the driver once the wait
   ends; the EOSE path gets happens-before from the channel, the idle path
   had none, so the driver could read a stale pageMinTs (ending the walk
   early) or an unsafely published idsAtPageMin. The volatile IdleClock
   bump now runs in a finally, so it covers every event including the
   early-returning duplicate and orders after the counters.

Also folded the single-relay count channel close into its finally, so a
throw mid-wait cleans up like every sibling accessory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 03:26:58 +00:00
Claude ac6034f764 refactor: drop fetchAllPages' maxPageMs ceiling
The per-page wall-clock ceiling added alongside the idle-window change did
not do what it claimed. fetchAllPages waits inside a while(true): when a
page's wait ends, the loop advances the until cursor and issues the next
REQ rather than returning, so a ceiling bounds one page, never the call.
Measured against a relay trickling events forever with an unbounded
filter, maxPageMs=400 produced 8 REQs and no return -- the walk ran until
the caller cancelled, exactly as it would with no ceiling at all.

It also made truncation unsafe. Cutting a page mid-stream advances until
to the oldest event received so far, which only preserves the set if the
relay streams strictly newest-first -- NIP-01 recommends that but does not
require it -- so a ceiling firing on an out-of-order relay can skip the
not-yet-sent events above that cursor. That is the same class of silent
gap the idle window was introduced to close.

What actually bounds a paged download is the filter's limit (already the
documented way) or cancelling the caller, which the ensureActive() at the
top of each page honors. Removed from fetchAllPages, fetchAllPagesFromPool
and fetchAllPagesFromPoolWithHooks; a regression test pins the real
contract so nobody re-adds a ceiling believing it caps the walk.

maxTotalMs stays on fetchAll/fetchAllWithHooks, fetchFirst and multi-relay
count: those wait in a single loop and return, so there the ceiling
genuinely ends the call (each is covered by a test asserting it does).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 03:13:53 +00:00
Claude 53be8d1755 fix: harden accessory timeout ceilings and count() listener cleanup
Audit follow-ups on the idle-window normalization:

- maxTotalMs/maxPageMs defaults are timeoutMs * 10, which silently
  overflows to a negative Long for an effectively-infinite idle window
  (Long.MAX_VALUE * 10 wraps to -10). withTimeoutOrNull then expired
  immediately, inverting 'wait forever' into 'never wait'. All three
  ceilings (fetchAllPages, fetchFirst, fetchAllWithHooks) now treat a
  non-positive ceiling as uncapped, matching the idle window's <= 0
  convention. Regression-tested via fetchFirst.
- count(filters) leaked its RelayConnectionListener and left COUNT subs
  open if the caller cancelled mid-wait or a listener threw: the cleanup
  ran as straight-line code with no try/finally, unlike every sibling
  accessory. Wrapped so unsubscribe + removeConnectionListener + channel
  close always run.
- New FetchFirstIdleTimeoutTest pins fetchFirst's idle-window semantics
  (signals restart the window, silence costs exactly one window, the
  ceiling stops endless terminal chatter, overflow means uncapped).
- README: note publishAndConfirm's fixed window as the deliberate
  exception, and correct the fetchAllPagesFromPool row, which claimed
  cross-relay dedup the function explicitly does not do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 02:48:15 +00:00
Claude 431ad153bc fix: normalize accessory timeouts to idle windows (time since last relay message)
fetchAllPages waited for each page's EOSE under a hard wall-clock
withTimeoutOrNull, unlike fetchAll / fetchAllWithHooks / the negentropy
sync, whose timeouts are idle windows reset by every arriving message.
A slow-but-streaming page could be silently truncated, and a relay
slower than timeoutMs to first byte was mistaken for a drained set.

- Extract IdleClock + receiveWithinIdle out of NostrClientNegentropySyncExt
  into a shared internal IdleWatchdog.kt and document the package-wide
  convention in the accessories README.
- fetchAllPages (+ pool/hooks variants): the per-page timeout is now an
  idle window bumped by every event; a maxPageMs ceiling (10x, matching
  fetchAllWithHooks' maxTotalMs) backstops a never-EOSE trickle. A
  non-positive ceiling means uncapped, mirroring the idle <= 0 convention.
- fetchFirst: the wait restarts on every arriving signal instead of one
  hard deadline across all relays; maxTotalMs ceiling added.
- count (multi-relay): each arriving COUNT result restarts the window;
  maxTotalMs ceiling added. Single-relay count is trivially idle already.
- NegentropyStoreSync page fallback clamps a disabled watchdog (0) to
  DEFAULT_DOWNLOAD_IDLE_MS like fetchByIds, instead of paging unbounded.
- New NostrClientFetchAllPagesIdleTimeoutTest pins the semantics
  (verified to fail against the old hard-deadline wait).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 02:35:10 +00:00
Vitor PamplonaandGitHub bf41e75b78 Merge pull request #3848 from vitorpamplona/fix/count-ignores-default-limit
Stop COUNT from inheriting the default page size
2026-08-02 13:15:43 -04:00
Vitor PamplonaandClaude Opus 5 a76a1911ea Stop COUNT from inheriting the default page size
A relay holding 12,289,614 profiles answers

  ["COUNT","c1",{"kinds":[0]}]  ->  {"count":500}

LimitsPolicy ran the same clampLimits over CountCmd as over ReqCmd, so
an unbounded COUNT was given RelayLimits.defaultLimit and the store then
counted at most that many.

defaultLimit answers "how many events should a REQ return when the client
names none". A COUNT returns no events, so the question has no meaning
for it, and applying the answer anyway turns every unbounded COUNT into
min(matches, defaultLimit).

The failure is quiet, which is what let it survive: 500 is a plausible
number, and the kinds under the default answered correctly. On the relay
that surfaced it, kinds 1 and 10040 were right while 0, 10002 and 30382
were all exactly 500.

maxLimit still applies — a client asking to count at most N is asking
something a relay may bound. Only the invented default is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:14:07 -04:00
Vitor PamplonaandGitHub e10f76665f Merge pull request #3846 from vitorpamplona/claude/30382-rank-follower-providers-j2cnb9
fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
2026-08-01 21:17:36 -04:00
Claude 6cd91bb058 fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
updateFilter only added the rank provider to the trusted-author set, so
when the follower-count provider differed (different pubkey and/or
relay), its kind:30382 cards were never requested from the relay.
followerCountStrFlow then filtered for a signer whose cards never
arrived and rendered "--" forever.

Add liveUserFollowerCount (with its relayUrl) into the same mapOfSet
block, symmetrically with liveUserRankProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7AA1AxqA6StnPVdjGDcwv
2026-08-02 00:43:27 +00:00
Vitor PamplonaandGitHub 7922f9a4d1 Merge pull request #3845 from vitorpamplona/claude/explained-filter-test-warnings-clpcv8
Test: Type ExplainedFilter.copy() result as base Filter
2026-08-01 14:08:56 -04:00
45 changed files with 1861 additions and 264 deletions
@@ -971,7 +971,7 @@ class AccountConcordActions(
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey))
// Stock relays like relay.ditto.pub can be slow (~1020s to first response), so give
// the fetch a generous window to drain every relay before we pick the newest copy.
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L)
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L)
val newest = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0
Log.d(
@@ -1015,7 +1015,7 @@ class AccountConcordActions(
}
if (filters.isEmpty()) return
val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } }
account.client.fetchAll(filters = byRelay, timeoutMs = 20_000L)
account.client.fetchAll(filters = byRelay, idleTimeoutMs = 20_000L)
}
/**
@@ -133,7 +133,7 @@ class AccountRelayGroupActions(
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
account.client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
results = account.client.publishAndCollectResults(signed, setOf(relay))
@@ -255,7 +255,7 @@ class AccountNappletGateways(
emptyList()
} else {
runCatching {
account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
}.getOrDefault(emptyList())
}
val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } }
@@ -144,7 +144,7 @@ class NappletResourceFetcher(
val relays = account.homeRelays.flow.value
if (relays.isEmpty()) return null
return runCatching {
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS)
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS)
}.getOrDefault(emptyList())
.maxByOrNull { it.createdAt }
}
@@ -84,9 +84,14 @@ class UserCardsSubAssembler(
add(it, account.userProfile().pubkeyHex)
}
}
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account ->
if (account != null) {
add(account.relayUrl, account.pubkey)
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
}
@@ -153,7 +153,7 @@ class AgentConsoleViewModel : ViewModel() {
// (pendingOnAuthRequired) so it authenticates on the `auth-required` CLOSED and retries.
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
@@ -97,7 +97,7 @@ private suspend fun runBuzzDmDiscovery(
// rather than returning empty.
account.client.fetchAllWithHooks(
filters = relays.associateWith { discoveryFilters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) }
@@ -135,7 +135,7 @@ private suspend fun fetchDmMetadata(
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/**
@@ -200,7 +200,7 @@ class BuzzDmListViewModel : ViewModel() {
)
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay }
@@ -215,7 +215,7 @@ class BuzzDmListViewModel : ViewModel() {
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/**
@@ -149,7 +149,7 @@ class BuzzRelayImportViewModel : ViewModel() {
),
),
),
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) }
@@ -172,7 +172,7 @@ class BuzzRelayImportViewModel : ViewModel() {
Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())),
),
),
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
@@ -502,7 +502,7 @@ class EventSync(
try {
client.fetchAllPagesFromPool(
filters = perRelayFilters,
timeoutMs = RELAY_TIMEOUT_MS,
idleTimeoutMs = RELAY_TIMEOUT_MS,
maxConcurrentRelays = MAX_CONCURRENT_RELAYS,
onNewPage = { until, sourceRelay ->
_liveActivity.value.runningRelays[sourceRelay]
@@ -199,7 +199,7 @@ class CashuWalletDiscovery(
fetchAllPages(
relay = relay,
filters = filters,
timeoutMs = RELAY_TIMEOUT_MS,
idleTimeoutMs = RELAY_TIMEOUT_MS,
onEvent = onEvent,
)
}.onFailure {
@@ -1942,14 +1942,95 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d पुनःप्रसारक</item>
<item quantity="other">%1$s \u00b7 %2$d पुनःप्रसारक</item>
</plurals>
<string name="relay_purpose_browsing">जालभ्रमण</string>
<string name="relay_purpose_media">ध्वनिचित्राभिलेख</string>
<string name="relay_purpose_tags">विषयसूचक</string>
<string name="relay_purpose_topics">विषय सूची</string>
<string name="relay_purpose_thread">वार्तालाप</string>
<string name="relay_purpose_search">खोज</string>
<string name="relay_purpose_referenced">लुप्त घटनाओं को ढूँढें</string>
<string name="relay_purpose_engagement">घटना अवलोकन</string>
<string name="relay_explain_referenced">घटनाओं को विभेदक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल।</string>
<string name="relay_explain_engagement">घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं।</string>
<string name="relay_purpose_add_ons">संलग्न</string>
<string name="relay_purpose_relay_info">पुनःप्रसारक जानकारी</string>
<string name="relay_purpose_other">अन्य</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">पुनःप्रसारक सूची खोजकर्ता</string>
<string name="relay_purpose_observing_profiles">परिचय अवलोकन</string>
<string name="relay_purpose_your_account">लेखा जानकारी</string>
<string name="relay_purpose_home_feed">मुख्य सूचनावली</string>
<string name="relay_purpose_relay_groups">पुनःप्रसारक समूह</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d समूह</item>
<item quantity="other">%1$d समूह</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">अस्थायी चर्चाएँ</string>
<string name="relay_purpose_geohash_chats">स्थानीय चर्चाएँ</string>
<string name="relay_purpose_live_chat">वर्तमानप्रवाह चर्चा</string>
<string name="relay_explain_relay_groups">निप॰२९ समूह जिनसे आप जुडे हैं। प्रत्येक समूह एक जालावास पुनःप्रसारक में रहता है। इसलिए क्रमक प्रत्येक पुनःप्रसारक से संयोजन करता है जो आपके किसी समूह का जालावास है।</string>
<string name="relay_explain_ephemeral_chats">चर्चाशालाएँ जो कोई इतिहास नहीं रखते। सन्देश केवल तब तक रहते हैं जब तक आप संयोजित हैं। इसलिए ये ग्राहकता बनाए रखते हैं कुछ भी प्राप्त होने के लिए।</string>
<string name="relay_explain_geohash_chats">स्थान आधारित शालाएँ उन क्षेत्रों के लिए जिनका आप अनुगमन करते हैं। पृष्ट उन पुनःप्रसारको से जो इनके जालावास हैं।</string>
<string name="relay_explain_live_chat">चर्चा तथा ज्साप उद्देश्य जो वर्तमानप्रवाहों से संलग्न हैं जिन्हें आप खोले हुए हैं अथवा अनुगमन करते हैं।</string>
<string name="relay_purpose_dm_inbox">सीधासन्देश आगतपेटिका</string>
<string name="relay_purpose_your_wallet">धनकोष</string>
<string name="relay_purpose_nutzap_inbox">नटज्साप आगतपेटिका</string>
<string name="relay_purpose_mint_directory">टकसाल निर्देशिका</string>
<string name="relay_purpose_nwc">धनकोष संयोजन</string>
<string name="relay_purpose_community_chats">समुदाय चर्चाएँ</string>
<string name="relay_purpose_community_feeds">समुदाय सूचनावलियाँ</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">आपके आगतपेटिका पुनःप्रसारक तथा कुछ अल्पमात्रा परिभ्रमणवर्ती दृष्टान्त पुनःप्रसारक जिनपर आपके अनुचरित पत्र प्रकाशित करते हैं। यदि कोई उल्लेख अन्यत्र भेजा गया।</string>
<string name="relay_explain_direct_messages">आपके सीधासन्देश पुनःप्रसारक। जहाँ उपहारकोषयुक्त सन्देश भेजे जाते हैं।</string>
<string name="relay_explain_public_chats">मुख्य पुनःप्रसारक प्रत्येक चर्चा का जिन्हें आप खोल रखे हैं अथवा जिनसे आप जुड चुके हैं।</string>
<string name="relay_explain_community_chats">पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने पत्र प्रकाशित करते हैं।</string>
<string name="relay_explain_encrypted_groups">समूह सन्देश तथा कुंचिकापेटलियाँ। प्रत्येक समूह के पुनःप्रसारकों पर।</string>
<string name="relay_explain_live_rooms">शाला के पुनःप्रसारक। जब वह खुला हो।</string>
<string name="relay_explain_account_data">आपके अपने परिचय तथा स्थापना विकल्प तथा पाण्डुलिपियाँ। आपके मुख्य पुनःप्रसारकों पर।</string>
<string name="relay_explain_profiles">वर्तमानतः पटल पर लोगों के परिचय।</string>
<string name="relay_explain_relay_lists">खोजता है किन पुनःप्रसारकों पर प्रत्येक व्यक्ति प्रकाशन करता है। जिससे कि उनके पत्र सम्यक स्थल से प्राप्प हो।</string>
<string name="relay_explain_follows">अनुचरण सूचियाँ। आपकी सूचनावली तथा आपका विश्वासजाल का निर्माण के लिए उपयुक्त।</string>
<string name="relay_explain_moderation">वृत्तान्त जो आपके अनुचरितों ने लिखा वर्तमानतः आपके पटल पर दिखनेवाले परिचयों के विषय में। पृष्ट प्रत्येक पुनःप्रसारक से जिनपर वे पत्र प्रकाशन करते हैं।</string>
<string name="relay_purpose_reports_from_follows">अनुचरित से वृत्तान्त</string>
<string name="relay_explain_wallet">आपके अपने धनकोष घटनाएँ। पुनःपठित उन पुनःप्रसारकों से जिनपर आपने उनके प्रकाशन किए।</string>
<string name="relay_explain_nutzap_inbox">सुनता है आपके नटज्साप पुनःप्रसारकों पर तथा आपके आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों पर। जिससे कि कोई भी भुगतान छूट ना जाए।</string>
<string name="relay_explain_mint_directory">पुनःप्रसारकों का वीक्षण करता है यह देखने के लिए कि कौनसे टकसाल हैं तथा लोग किनकी अनुशम्सा करते हैं।</string>
<string name="relay_explain_nwc">आपके संयोजित धनकोष से सूचनाएँ।</string>
<string name="active_subs_title">सक्रिय पुनःप्रसारक ग्राहकताएँ</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d छलनी</item>
<item quantity="other">%1$d छलनियाँ</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d पुनःप्रसारक</item>
<item quantity="other">%1$d पुनःप्रसारक</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d छलनी आरोपित नहीं अब तक</item>
<item quantity="other">%1$d छलनियाँ आरोपित नहीं अब तक</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">किसी लेखा प्रति आरोपित नहीं</string>
<string name="active_subs_no_entity">सभी</string>
<string name="active_subs_scope_global">सभी</string>
<string name="active_subs_scope_follows">आपके द्वारा अनुचरित लोग</string>
<string name="active_subs_scope_authors">चयनित लोगों की सूची</string>
<string name="active_subs_scope_muted">मौनकृत लोग</string>
<string name="active_subs_scope_all_communities">आपके समुदाय</string>
<string name="active_subs_scope_algo">एक प्रिय कलनविधि सूचनावली</string>
<string name="active_subs_share">%1$dप्रतिशतप्रतिशत सब में से</string>
<string name="active_subs_search_keywords">ग्राहकताएँ छलनियाँ पुनःप्रसारक अनुरोध अनु॰ संयोजन क्यों निदानतन्त्र</string>
<string name="relay_explain_home">आपके अनुचरितों के पत्र। पठित उन पुनःप्रसारकों से जिनपर उनमें से प्रत्येक प्रकाशन करते हैं।</string>
<string name="always_on_notif_connecting">आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026</string>
<string name="always_on_notif_setting_title">सदैव सक्रिय सूचना सेवा</string>
<string name="always_on_notif_setting_description">अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे।</string>
File diff suppressed because it is too large Load Diff
@@ -153,12 +153,12 @@ class AmethystAppFunctions {
// Quartz's INostrClient.fetchAll handles subscribe → drain on
// EOSE/closed/cannot-connect → unsubscribe → dedup by id → sort
// newest-first. Wraps everything in a withTimeoutOrNull(timeoutMs)
// newest-first. Wraps everything in a withTimeoutOrNull(idleTimeoutMs)
// so a slow relay can't stall the dispatch.
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val candidates =
@@ -397,7 +397,7 @@ class AmethystAppFunctions {
return Amethyst.instance.client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? TextNoteEvent }
.take(limit)
}
@@ -449,7 +449,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -521,7 +521,7 @@ class AmethystAppFunctions {
client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? MetadataEvent }
.filter { it.pubKey == pubkey }
.maxByOrNull { it.createdAt }
@@ -569,7 +569,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -643,7 +643,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -686,7 +686,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -733,7 +733,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -785,7 +785,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val receipts = events.mapNotNull { it as? LnZapEvent }
@@ -880,7 +880,7 @@ class AmethystAppFunctions {
client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? GiftWrapEvent }
val seen = HashSet<HexKey>()
@@ -947,7 +947,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -991,7 +991,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val streams =
@@ -1691,7 +1691,7 @@ class AmethystAppFunctions {
return client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? MetadataEvent }
.maxByOrNull { it.createdAt }
?.contactMetaData()
@@ -2027,7 +2027,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -547,7 +547,7 @@ class Context(
if (needAuth.isEmpty()) break
// A cheap REQ whose only purpose is to force the AUTH handshake to completion.
val warmFilter = listOf(Filter(kinds = listOf(event.kind), limit = 1))
drain(needAuth.associateWith { warmFilter }, timeoutMs = 8_000, pendingOnAuthRequired = true)
drain(needAuth.associateWith { warmFilter }, idleTimeoutMs = 8_000, pendingOnAuthRequired = true)
results = results + client.publishAndCollectResults(event, needAuth, timeoutSecs)
attempt++
}
@@ -565,7 +565,7 @@ class Context(
* When [deadOut] is provided, every relay that reported it could not be
* connected to (`onCannotConnect`) is added to it, so callers can prune
* proven-dead relays from future routing instead of paying the full
* [timeoutMs] on them again. Slow-but-connected relays are NOT reported —
* [idleTimeoutMs] on them again. Slow-but-connected relays are NOT reported —
* only hard connect failures, so a temporarily-busy relay isn't discarded.
*
* With [pendingOnAuthRequired], a relay that refuses the REQ with an
@@ -573,24 +573,24 @@ class Context(
* NIP-42 responder answers the challenge and the client re-fires this same
* subscription (`syncFilters`), so the post-auth events are collected instead of
* returning empty. If auth never satisfies it, the relay simply falls through to
* the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
* the [idleTimeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
* only to a connection authenticated as the derived stream key.
*/
suspend fun drain(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 8_000,
idleTimeoutMs: Long = 8_000,
diagnoseSlow: Boolean = false,
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
pendingOnAuthRequired: Boolean = false,
): List<Pair<NormalizedRelayUrl, Event>> =
client.fetchAllWithHooks(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
pendingOnAuthRequired = pendingOnAuthRequired,
deadOut = deadOut,
onTimeout =
if (diagnoseSlow) {
{ stalled, doneReasons, collected -> logSlowDrain(timeoutMs, stalled, doneReasons, collected) }
{ stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) }
} else {
null
},
@@ -604,7 +604,7 @@ class Context(
* "relay is slow" and "we never connected" are easy to tell apart.
*/
private fun logSlowDrain(
timeoutMs: Long,
idleTimeoutMs: Long,
stalled: Set<NormalizedRelayUrl>,
doneReasons: Map<NormalizedRelayUrl, String>,
collected: List<Pair<NormalizedRelayUrl, Event>>,
@@ -615,7 +615,7 @@ class Context(
val slowDetail = stalled.take(12).joinToString(", ") { "${it.url}(${eventsPer[it] ?: 0}ev)" }
val cannotDetail = cannot.entries.take(8).joinToString(", ") { "${it.key.url}=${it.value.removePrefix("cannot:").take(40)}" }
System.err.println(
"[drain] timeout ${timeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
"[drain] timeout ${idleTimeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
(if (slowDetail.isNotEmpty()) " | slow: $slowDetail" else "") +
(if (cannotDetail.isNotEmpty()) " | cannot: $cannotDetail" else ""),
)
@@ -641,12 +641,12 @@ class Context(
*/
suspend fun drainAllPages(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000,
idleTimeoutMs: Long = 30_000,
maxConcurrentRelays: Int = 8,
): List<Pair<NormalizedRelayUrl, Event>> =
client.fetchAllPagesFromPoolWithHooks(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
maxConcurrentRelays = maxConcurrentRelays,
) { _, event -> verifyAndStore(event) }
@@ -112,7 +112,7 @@ object AwaitCommands {
val event =
ctx.client.fetchFirst(
filters = relays.associateWith { listOf(filter) },
timeoutMs = 3_000,
idleTimeoutMs = 3_000,
)
if (event is KeyPackageEvent) {
Output.emit(
@@ -357,7 +357,7 @@ object DmCommands {
.groupBy { it.relay }
.mapValues { (_, v) -> v.map { it.filter } }
val raw = ctx.drain(filters, timeoutMs = timeoutSecs * 1000)
val raw = ctx.drain(filters, idleTimeoutMs = timeoutSecs * 1000)
val messages = decryptDms(ctx, raw, peerHex)
val out =
@@ -415,7 +415,7 @@ object DmCommands {
.groupBy { it.relay }
.mapValues { (_, v) -> v.map { it.filter } }
val raw = ctx.drain(filters, timeoutMs = 3_000)
val raw = ctx.drain(filters, idleTimeoutMs = 3_000)
val messages = decryptDms(ctx, raw, peerHex)
// Match against the text body for kind:14 and against the URL
// for kind:15 — both are exposed as `searchText` so callers
@@ -103,7 +103,7 @@ object GitReadCommands {
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) },
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).asSequence()
.map { it.second }
.filter { it.kind == itemKind }
@@ -160,7 +160,7 @@ object GitReadCommands {
relays.associateWith {
listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id))))
},
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).map { it.second }
.distinctBy { it.id }
@@ -208,7 +208,7 @@ object GitReadCommands {
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to chunk))) },
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).map { it.second }
}.filterIsInstance<GitStatusEvent>()
.distinctBy { it.id }
@@ -97,7 +97,7 @@ object GroupAddMemberCommand {
client = ctx.client,
targetPubKey = pub,
relays = kpRelays,
timeoutMs = 10_000,
idleTimeoutMs = 10_000,
)
if (kpEvent == null) {
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
@@ -99,7 +99,7 @@ object KeyPackageCommands {
client = ctx.client,
targetPubKey = targetHex,
relays = relays,
timeoutMs = 10_000,
idleTimeoutMs = 10_000,
)
if (event == null) {
return Output.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
@@ -287,7 +287,7 @@ object GrapeRankCrawl {
// Default null → pull EVERY follower each relay holds; --max
// N caps the total per relay for a quick spot check.
maxPerRelay = args.flag("max")?.toIntOrNull(),
timeoutMs = args.timeoutMs(15),
idleTimeoutMs = args.timeoutMs(15),
maxConcurrentRelays = relayConcurrency,
insertBatchSize = args.intFlag(FLAG_INSERT_BATCH, INSERT_BATCH_DEFAULT),
),
@@ -1,17 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Login & Auth -->
<string name="login_title">Üdvözöljük az Amethystben</string>
<string name="login_subtitle">Jelentkezzen be Nostr a-fiókjába</string>
<string name="login_subtitle_desktop">Asztali Nostr-kliens</string>
<string name="login_card_title">Jelentkezzen be Nostr-a kulcsával</string>
<string name="login_card_subtitle">nsec a teljes hozzáféréshez, bunker:// a távoli aláíróhoz, vagy npub a csak olvasható módhoz</string>
<string name="login_with_key">Bejelentkezés kulccsal</string>
<string name="login_button">Bejelentkezés</string>
<string name="login_generate_new">Új kulcs előállítása</string>
<string name="login_generate_button">Új előállítása</string>
<string name="login_key_hint">Adja meg a privát kulcsát (nsec) vagy a nyilvános kulcsát (npub)</string>
<string name="login_key_label">nsec, bunker:// vagy npub</string>
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
<string name="login_show_key">Kulcs megjelenítése</string>
<string name="login_hide_key">Kulcs elrejtése</string>
<!-- New Key Warning -->
<string name="new_key_warning_title">FONTOS: Mentse el a kulcsait!</string>
<string name="new_key_warning_message">A titkos kulcsa (nsec) az EGYETLEN módja annak, hogy hozzáférjen a fiókjához. Ha elveszíti, akkor a fiókja végleg elvész. Mentse el egy biztonságos helyre!</string>
<string name="new_key_public_label">Nyilvános kulcs (megosztható):</string>
<string name="new_key_secret_label">Titkos kulcs (SOHA ne ossza meg!):</string>
<string name="new_key_continue_button">Elmentettem a kulcsaimat, folytatás</string>
<!-- Common Actions -->
<string name="action_copy">Másolás</string>
<string name="action_paste">Beillesztés</string>
<string name="action_cancel">Mégse</string>
<string name="action_ok">OK</string>
<string name="action_save">Mentés</string>
<string name="action_delete">Törlés</string>
<string name="action_share">Megosztás</string>
<!-- Errors -->
<string name="error_invalid_key">Érvénytelen kulcsformátum. Ellenőrizze, és próbálja újra.</string>
<string name="error_network">Hálózati hiba. Ellenőrizze a kapcsolatot.</string>
<string name="error_generic">Hiba történt. Próbálja újra.</string>
<!-- Loading & Empty States -->
<string name="action_refresh">Frissítés</string>
<string name="action_try_again">Próbálja újra</string>
<string name="feed_empty">A hírfolyam üres</string>
<string name="error_loading_feed">Hiba történt a hírfolyam betöltésekor: %s</string>
<!-- Placeholder Screens -->
<string name="screen_search_title">Keresés</string>
<string name="screen_search_description">Keressen felhasználókat, bejegyzéseket és kulcsszavakat.</string>
<string name="screen_messages_title">Üzenetek</string>
<string name="screen_messages_description">Az Ön titkosított közvetlen üzenetei itt fognak megjelenni.</string>
<string name="screen_notifications_title">Értesítések</string>
<string name="screen_notifications_description">Az említések, válaszok és reakciók itt fognak megjelenni.</string>
<!-- Accessibility -->
<string name="accessibility_user_avatar">Felhasználó profilképe</string>
<string name="accessibility_navigate">Navigáció</string>
<!-- Relay history paging (shared feed markers + status card) -->
<string name="chats_history_loading_label">Betöltés:</string>
<string name="chats_history_fully_loaded_label">Teljesen betöltve:</string>
<string name="chats_history_fully_loaded">(teljesen betöltve)</string>
<string name="chats_history_by_relay">Előzmények átjátszónként</string>
<string name="chats_history_stalled_retry">Újra megpróbálja, amint újra megnyitja ezt a képernyőt</string>
<string name="chats_history_older">%1$s korábbi üzenet</string>
<string name="chats_history_all_caught_up">Naprakész</string>
<string name="chats_history_reached_start">Elérte a(z) %1$s üzeneteinek elejét</string>
<string name="chats_history_subtitle">%1$s · %2$s · betöltve ekkortól: %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">várakozás erre: %1$s</string>
<string name="chats_history_incomplete">Néhány átjátszó nem válaszolt</string>
<string name="chats_history_incomplete_sub">%1$s nem érhető el · koppintson a részletekért</string>
<string name="chats_history_relays_title">%1$s · előzmények átjátszónként</string>
<string name="chats_history_relay_since">ekkortól: %1$s</string>
<string name="action_dismiss">Eltüntetés</string>
<plurals name="chats_history_relays">
<item quantity="one">%1$d átjátszó</item>
<item quantity="other">%1$d relé</item>
</plurals>
<!-- Notes & Replies -->
<string name="replying_to">válasz neki: </string>
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">nOldal: %1$s</string>
<string name="napplet_card_title">nKisalkalmazás: %1$s</string>
<string name="napplet_card_kind">nKisalkalmazás</string>
<string name="nsite_website_kind">nOldal</string>
<string name="napplet_card_permissions">Amihez hozzáférhet</string>
<string name="nsite_root_site">Gyökéroldal</string>
<string name="nsite_source">Forrás:</string>
<string name="nsite_servers">Kiszolgálók:</string>
<string name="nsite_open">Megnyitás</string>
<!-- Custom emoji suggestions (NIP-30) -->
<string name="use_direct_url">Közvetlen webcím használata</string>
<!-- Nicknames (NIP-85 contact cards) -->
<string name="nickname_dialog_title">Becenév</string>
<string name="nickname_dialog_explainer">Ez jelenik meg Önnek ezen felhasználó neve helyett az alkalmazásban bárhol. Titkosítva tárolódik el a kapcsolatkártyájára: csak Ön olvashatja. Írjon be kettőspontot (:) az egyéni emodzsik használatához.</string>
<string name="nickname_label">Becenév</string>
<string name="nickname_summary_label">Privát megjegyzés erről a felhasználóról</string>
<string name="nickname_save">Mentés</string>
<string name="nickname_cancel">Mégse</string>
<string name="git_status_open">Nyitva</string>
<string name="git_status_merged">Beolvasztva</string>
<string name="git_status_closed">Lezárva</string>
@@ -53,6 +131,7 @@
<string name="road_event_traffic_jam">Forgalmi dugó</string>
<string name="road_event_unknown">Útesemény</string>
<string name="podcast_value_zap_split_hint">Az erre küldött Zapek megoszlanak a következők között:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Értéket az értékért</string>
<string name="relay_monitor_rtt_open">Megnyitás</string>
<string name="relay_monitor_rtt_read">Olvasás </string>
@@ -61,6 +140,7 @@
<string name="relay_monitor_relay_type">Típus</string>
<string name="relay_monitor_requirements">Követelmények</string>
<string name="relay_monitor_supported_nips">Támogatott NIP-ek</string>
<string name="relay_monitor_ms">%1$d ms</string>
<string name="relay_discovery_accepted_kinds">Elfogadott típusok</string>
<string name="relay_discovery_geohash">Helyszín</string>
<string name="calendar_rsvp_going">Ott leszek</string>
@@ -147,7 +147,7 @@ class DmInboxRelayResolver(
if (writeRelays.isNotEmpty()) {
relays =
RecipientRelayFetcher
.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, timeoutMs = 5_000L)
.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, idleTimeoutMs = 5_000L)
.dmInbox
}
}
@@ -221,7 +221,7 @@ class DesktopRelaySubscriptionsCoordinator(
val events =
client.fetchAll(
filters = indexRelays.associateWith { listOf(filter) },
timeoutMs = 8.seconds.inWholeMilliseconds,
idleTimeoutMs = 8.seconds.inWholeMilliseconds,
)
events.forEach { consumeEvent(it, null) }
}
+13 -10
View File
@@ -84,27 +84,34 @@
"Dutch"
]
},
{
"user": "summoner001",
"languages": [
"Hungarian"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "vitorpamplona",
"languages": [
"Czech",
"German",
"Polish",
"Portuguese, Brazilian",
"Swedish"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "greenart7c3",
"languages": []
@@ -265,10 +272,6 @@
"user": "D4rkFIow",
"languages": []
},
{
"user": "summoner001",
"languages": []
},
{
"user": "fiddleway",
"languages": []
@@ -75,14 +75,14 @@ class FollowerCrawler(
* stops paging once it's reached, so a non-null value cuts the crawl short at
* that many per relay. Leave it null for completeness; set it only to bound a
* spot check. The per-page size is the relay's own default either way.
* @param timeoutMs per-page EOSE timeout for a relay before its next page fires.
* @param idleTimeoutMs per-page EOSE timeout for a relay before its next page fires.
* @param maxConcurrentRelays how many relays page at once (a global fan-out cap).
* @param insertBatchSize verified events group-committed per [IEventStore.batchInsert].
*/
class Config(
val relays: Set<NormalizedRelayUrl>,
val maxPerRelay: Int? = null,
val timeoutMs: Long = 15_000,
val idleTimeoutMs: Long = 15_000,
val maxConcurrentRelays: Int = 16,
val insertBatchSize: Int = 500,
)
@@ -162,7 +162,7 @@ class FollowerCrawler(
client.fetchAllPagesFromPool(
filters = perRelay,
timeoutMs = config.timeoutMs,
idleTimeoutMs = config.idleTimeoutMs,
maxConcurrentRelays = config.maxConcurrentRelays,
onRelayComplete = { relay, total ->
if (total > 0) log("[followers] ${relay.url}: $total kind:3 pages drained")
@@ -81,7 +81,7 @@ object RecipientRelayFetcher {
client: INostrClient,
pubKey: HexKey,
seedRelays: Set<NormalizedRelayUrl>,
timeoutMs: Long = 8_000L,
idleTimeoutMs: Long = 8_000L,
): Lists {
if (seedRelays.isEmpty()) return Lists(emptyList(), emptyList(), null)
@@ -99,7 +99,7 @@ object RecipientRelayFetcher {
val events =
client.fetchAll(
filters = seedRelays.associateWith { listOf(filter) },
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
)
var dm: ChatMessageRelayListEvent? = null
@@ -75,11 +75,11 @@ object KeyPackageFetcher {
client: INostrClient,
targetPubKey: HexKey,
relays: Set<NormalizedRelayUrl>,
timeoutMs: Long = 30_000,
idleTimeoutMs: Long = 30_000,
): KeyPackageEvent? {
if (relays.isEmpty()) return null
val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey)
val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = idleTimeoutMs)
// fetchAll returns events sorted by created_at DESC, so the first
// KeyPackageEvent is the most recent one any relay had.
return events.firstNotNullOfOrNull { it as? KeyPackageEvent }
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
import kotlin.time.TimeSource
/**
* Monotonic "last activity" marker for the idle watchdogs shared by the accessory
* fetch/sync loops. [bump] on every sign of life from the relay; [elapsedMs] reports
* the silence since the last bump (or since construction, before the first bump).
*
* This is the timeout convention for every accessory in this package: an
* `idleTimeoutMs` is an **idle window measured from the relay's most recent
* progress**, not a wall-clock deadline — an actively streaming relay is never cut
* off mid-delivery, only one that goes silent. The name is the contract: a
* parameter here is called `idleTimeoutMs` precisely because it is not a deadline.
*
* [bump] is on the per-event hot path (a connection listener may bump for every
* message the relay sends — millions during a large download), so it must not
* allocate: a single [start] mark is taken once (unboxed field) and each bump only
* writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads
* write, the driver coroutine reads — visibility is all we need, so a plain volatile
* Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event.
*/
internal class IdleClock {
private val start = TimeSource.Monotonic.markNow()
@Volatile
private var lastNanos = 0L
fun bump() {
lastNanos = start.elapsedNow().inWholeNanoseconds
}
fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000
}
/**
* Receives the next item, giving up (returning `null`) only after [idleMs] elapse with
* no activity on [clock]. Because [clock] can be bumped by *any* relay message — not
* just items on this channel — unrelated progress (e.g. download events arriving during
* a reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the
* watchdog: it waits until an item arrives (a disconnect is delivered as an item, so
* a dead socket still unblocks it).
*/
internal suspend fun <T> Channel<T>.receiveWithinIdle(
clock: IdleClock,
idleMs: Long,
): T? {
if (idleMs <= 0) return receive()
while (true) {
val remaining = idleMs - clock.elapsedMs()
if (remaining <= 0) return null
val item = withTimeoutOrNull(remaining) { receive() }
if (item != null) return item
// Timed out with nothing on this channel. If other activity bumped the clock
// meanwhile, the next `remaining` is positive and we wait again; otherwise it
// is <= 0 on the next iteration and we give up.
}
}
@@ -298,7 +298,11 @@ class NegentropyStoreSync(
}
}
try {
client.fetchAllPages(relay, listOf(filter), config.idleTimeoutMs) { event -> events.trySend(event) }
// Like fetchByIds, a download keeps a finite idle bound even when the
// whole-sync watchdog is disabled (idleTimeoutMs = 0) — a page that
// never EOSEs must not hang the sync forever.
val pageIdleMs = if (config.idleTimeoutMs > 0) config.idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS
client.fetchAllPages(relay, listOf(filter), pageIdleMs) { event -> events.trySend(event) }
} finally {
events.close()
}
@@ -32,21 +32,27 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip45Count.HyperLogLog
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext
/**
* Sends a NIP-45 COUNT query to a single relay and suspends until
* the result arrives or the timeout expires.
*
* A COUNT exchange is a single response message, so [idleTimeoutMs] here is
* trivially the package-wide idle-window convention (time since the most
* recent message): no message can arrive before the one that completes it.
*
* @param relay Target relay to query.
* @param filter The filter to count against.
* @param timeoutMs How long to wait for a response (default 15 s).
* @param idleTimeoutMs How long to wait for the response (default 15 s).
* @return The [CountResult], or `null` on timeout.
*/
suspend fun INostrClient.count(
relay: NormalizedRelayUrl,
filter: Filter,
timeoutMs: Long = 15_000,
idleTimeoutMs: Long = 15_000,
): CountResult? {
val subId = newSubId()
val resultChannel = Channel<CountResult>(UNLIMITED)
@@ -64,23 +70,22 @@ suspend fun INostrClient.count(
}
}
addConnectionListener(listener)
return try {
addConnectionListener(listener)
val result =
try {
count(subId = subId, filters = mapOf(relay to listOf(filter)))
count(subId = subId, filters = mapOf(relay to listOf(filter)))
withTimeoutOrNull(timeoutMs) {
resultChannel.receive()
}
} finally {
unsubscribe(subId)
removeConnectionListener(listener)
withTimeoutOrNull(idleTimeoutMs) {
resultChannel.receive()
}
resultChannel.close()
return result
} finally {
// Every cleanup step belongs in the finally: closing the channel used to
// sit after it, so a throw (or cancellation) mid-wait skipped it while the
// sibling accessories all cleaned up fully.
unsubscribe(subId)
removeConnectionListener(listener)
resultChannel.close()
}
}
/**
@@ -88,13 +93,22 @@ suspend fun INostrClient.count(
* (one filter per relay) and suspends until all results arrive
* or the timeout expires.
*
* [idleTimeoutMs] is an **idle window measured from the most recent progress**, not a
* wall-clock deadline for the whole batch — the package-wide accessory
* convention: each *new* relay's COUNT result restarts it, so a large fan-out
* where results keep trickling in is never cut short. A relay re-sending a result
* it already gave is not progress and does not restart the window, which makes
* the call self-bounding (at most one window per relay). A caller wanting a hard
* wall-clock bound has `withTimeoutOrNull(ms) { count(...) }` — at the cost of
* discarding the partial map, which is why this returns whatever arrived instead.
*
* @param filters Map of relay -> filter to count.
* @param timeoutMs How long to wait for all responses (default 15 s).
* @param idleTimeoutMs Idle window between new responses (default 15 s).
* @return Map of relay -> [CountResult] for every relay that responded in time.
*/
suspend fun INostrClient.count(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 15_000,
idleTimeoutMs: Long = 15_000,
): Map<NormalizedRelayUrl, CountResult> {
if (filters.isEmpty()) return emptyMap()
@@ -115,27 +129,52 @@ suspend fun INostrClient.count(
}
}
addConnectionListener(listener)
filters.forEach { (relay, filterList) ->
val subId = newSubId()
subIdToRelay[subId] = relay
count(subId = subId, filters = mapOf(relay to filterList))
}
val results = mutableMapOf<NormalizedRelayUrl, CountResult>()
withTimeoutOrNull(timeoutMs) {
try {
addConnectionListener(listener)
filters.forEach { (relay, filterList) ->
val subId = newSubId()
subIdToRelay[subId] = relay
count(subId = subId, filters = mapOf(relay to filterList))
}
// One idle window per new relay result. The inner loop absorbs repeats
// (a relay answering twice) inside the SAME window, so only genuinely
// new information pushes the deadline out — bounding the call at one
// window per relay without needing a wall-clock ceiling.
while (results.size < filters.size) {
val (relay, result) = resultChannel.receive()
val progressed =
withTimeoutOrNull(idleTimeoutMs) {
while (true) {
// Cancellation (this window expiring, or the caller giving up)
// only lands at a suspension point, and receive() does not
// suspend while the channel has buffered results — so check
// explicitly rather than draining a backlog uninterruptibly.
coroutineContext.ensureActive()
val (relay, result) = resultChannel.receive()
// put() returns the previous value: null means this relay
// had not answered yet, i.e. real progress.
if (results.put(relay, result) == null) break
}
true
}
if (progressed == null) break
}
// A result can land after the last window closed but before we unsubscribe;
// it costs nothing to keep, and dropping it would understate the count.
while (true) {
val (relay, result) = resultChannel.tryReceive().getOrNull() ?: break
results[relay] = result
}
} finally {
subIdToRelay.keys.forEach { unsubscribe(it) }
removeConnectionListener(listener)
resultChannel.close()
}
subIdToRelay.keys.forEach { unsubscribe(it) }
removeConnectionListener(listener)
resultChannel.close()
return results
}
@@ -152,20 +191,20 @@ suspend fun INostrClient.count(
*
* @param relays List of relays to query.
* @param filter The filter to count against.
* @param timeoutMs How long to wait for all responses (default 15 s).
* @param idleTimeoutMs Idle window between responses (default 15 s) — see [count].
* @return A merged [CountResult], or `null` if no relay responded.
*/
suspend fun INostrClient.countMerged(
relays: List<NormalizedRelayUrl>,
filter: Filter,
timeoutMs: Long = 15_000,
idleTimeoutMs: Long = 15_000,
): CountResult? {
if (relays.isEmpty()) return null
val results =
count(
filters = relays.associateWith { listOf(filter) },
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
)
if (results.isEmpty()) return null
@@ -31,47 +31,47 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
suspend fun INostrClient.fetchAll(
relay: String,
filter: Filter,
timeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
relay: String,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
subscriptionId: String = newSubId(),
relay: String,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
relay: NormalizedRelayUrl,
filter: Filter,
timeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(relay to filters), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(relay to filters), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
subscriptionId: String = newSubId(),
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
) = fetchAll(subscriptionId, mapOf(relay to filters), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(subscriptionId, mapOf(relay to filters), idleTimeoutMs)
/**
* Subscribe [filters], collect every (deduped) event, and return once every
* relay reached a terminal state (EOSE, CLOSED, or cannot-connect) or the
* line went quiet for [timeoutMs].
* line went quiet for [idleTimeoutMs].
*
* [timeoutMs] is an **idle window, not a hard cap**: every arriving event or
* [idleTimeoutMs] is an **idle window, not a hard cap**: every arriving event or
* terminal signal resets it, so a slow relay actively streaming a large
* backlog is never cropped mid-delivery. The fetch only gives up after a full
* window of silence — or at the [maxTotalMs] wall-clock ceiling (default 10x
@@ -85,13 +85,13 @@ suspend fun INostrClient.fetchAll(
suspend fun INostrClient.fetchAll(
subscriptionId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000L,
maxTotalMs: Long = timeoutMs * 10,
idleTimeoutMs: Long = 30_000L,
maxTotalMs: Long = idleTimeoutMs * 10,
): List<Event> {
val seenIds = mutableSetOf<HexKey>()
return fetchAllWithHooks(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
subscriptionId = subscriptionId,
maxTotalMs = maxTotalMs,
) { _, event -> seenIds.add(event.id) }
@@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext
/**
@@ -72,14 +71,28 @@ import kotlin.coroutines.coroutineContext
*
* @param relay The relay to query.
* @param filters Filters to apply on every page (the `until` field is overwritten per page).
* @param timeoutMs Maximum time to wait for a single page's EOSE before giving up.
* @param idleTimeoutMs Idle window per page — like every accessory timeout, it is measured
* from the relay's **most recent message**, not from the page's start: every arriving
* event resets it, so a slow relay actively streaming a large page is never cropped
* mid-delivery. A page only gives up after this much silence without an EOSE.
*
* Deliberately no wall-clock ceiling here, unlike [fetchAll]'s `maxTotalMs`. A ceiling
* would bound one *page*, not this call: the loop below reacts to a page ending by
* advancing the cursor and issuing the next REQ, so a relay trickling events forever
* against an unbounded filter would just be re-paged forever — measurably so (see
* NostrClientFetchAllPagesIdleTimeoutTest). Worse, cutting a page mid-stream advances
* `until` to the oldest event received *so far*, which only preserves the set if the
* relay streams strictly newest-first (NIP-01 recommends but does not require it) —
* otherwise the not-yet-sent events above that cursor are skipped. What actually
* bounds this walk is a [Filter.limit] (the documented way to cap a download) or
* cancelling the caller, which the [ensureActive] at the top of each page honors.
* @param onEvent Called once for every distinct event delivered, in page order.
* @return Total number of distinct events delivered across all pages.
*/
suspend fun INostrClient.fetchAllPages(
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
onNewPage: ((Long) -> Unit)? = null,
onEvent: (Event) -> Unit,
): Int {
@@ -144,6 +157,11 @@ suspend fun INostrClient.fetchAllPages(
val doneChannel = Channel<Unit>(Channel.CONFLATED)
// Idle watchdog for this page: every arriving event bumps it, so the page's
// timeout measures silence since the relay's most recent message (the same
// convention as fetchAll and the negentropy sync), never total page time.
val clock = IdleClock()
// Captured for the listener: the boundary second we re-fetch this page.
val boundary = until
var received = 0
@@ -160,39 +178,62 @@ suspend fun INostrClient.fetchAllPages(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
received++
// Drop a boundary-second event we already delivered on an
// earlier page (the inclusive re-fetch returns it again).
if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return
// The bump is in a finally so it runs for EVERY event —
// including the duplicate that returns early below, which is
// still a sign of life — and, being a volatile write, runs
// AFTER the counters below. That ordering matters: these
// counters are written on the relay's reader thread and read
// by the driver coroutine once the wait ends. The EOSE path
// gets its happens-before from the channel, but the idle
// path has no such edge, so without the release write the
// driver could read a stale `pageMinTs` (ending the walk
// early) or an unsafely published `idsAtPageMin`.
try {
received++
// Drop a boundary-second event we already delivered on an
// earlier page (the inclusive re-fetch returns it again).
if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return
// Count this event against every active filter it satisfies
// (one event can match more than one). Only a non-search filter
// may advance the `until` cursor: a search hit — possibly old,
// relevance-ranked — must not drag the cursor back and make the
// next page skip events a co-resident normal filter still needs.
var atLeastOne = false
var advancesCursor = false
for ((index, filter) in activeFilters) {
if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) {
matchCountPerFilter[index]++
atLeastOne = true
if (filter.search == null) advancesCursor = true
}
}
if (atLeastOne) {
onEvent(event)
delivered++
// Track the oldest advancing second and the ids delivered
// in it — that becomes the next boundary and its dedup set.
if (advancesCursor) {
if (event.createdAt < pageMinTs) {
pageMinTs = event.createdAt
idsAtPageMin.clear()
idsAtPageMin.add(event.id)
} else if (event.createdAt == pageMinTs) {
idsAtPageMin.add(event.id)
// Count this event against every active filter it satisfies
// (one event can match more than one). Only a non-search filter
// may advance the `until` cursor: a search hit — possibly old,
// relevance-ranked — must not drag the cursor back and make the
// next page skip events a co-resident normal filter still needs.
var atLeastOne = false
var advancesCursor = false
// Indexed loop, not `for ((i, f) in activeFilters)`: this runs for
// EVERY event on the relay's reader thread (millions in a bulk
// download) and the destructuring form allocates an Iterator per
// event. Same reason quartz uses the `fast*` operators elsewhere
// in hot event paths — those only cover Array, so a List needs
// the index form.
for (i in activeFilters.indices) {
val active = activeFilters[i]
val index = active.index
val filter = active.value
if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) {
matchCountPerFilter[index]++
atLeastOne = true
if (filter.search == null) advancesCursor = true
}
}
if (atLeastOne) {
onEvent(event)
delivered++
// Track the oldest advancing second and the ids delivered
// in it — that becomes the next boundary and its dedup set.
if (advancesCursor) {
if (event.createdAt < pageMinTs) {
pageMinTs = event.createdAt
idsAtPageMin.clear()
idsAtPageMin.add(event.id)
} else if (event.createdAt == pageMinTs) {
idsAtPageMin.add(event.id)
}
}
}
} finally {
clock.bump()
}
}
@@ -222,9 +263,10 @@ suspend fun INostrClient.fetchAllPages(
subscribe(subId, mapOf(relay to activeFilters.map { it.value }), listener)
withTimeoutOrNull(timeoutMs) {
doneChannel.receive()
}
// Wait for the page's terminal signal (EOSE / CLOSED / cannot-connect),
// giving up only after [idleTimeoutMs] of silence — the wait resets on every
// arriving event, so an actively streaming page is never cut mid-delivery.
doneChannel.receiveWithinIdle(clock, idleTimeoutMs)
unsubscribe(subId)
doneChannel.close()
@@ -276,14 +318,14 @@ suspend fun INostrClient.fetchAllPages(
suspend fun INostrClient.fetchAllPages(
relay: String,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
onNewPage: ((Long) -> Unit)? = null,
onEvent: (Event) -> Unit,
): Int =
fetchAllPages(
relay = RelayUrlNormalizer.normalize(relay),
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
onNewPage = onNewPage,
onEvent = onEvent,
)
@@ -50,7 +50,10 @@ import kotlinx.coroutines.sync.Semaphore
* @param filters per-relay filter lists; the key set is the relays queried, in
* iteration order (pass a [LinkedHashMap]/`associateWith` result to control it).
* A `search` filter is fetched as a single relevance page — see [fetchAllPages].
* @param timeoutMs per-page EOSE timeout handed to each relay's [fetchAllPages].
* @param idleTimeoutMs per-page idle window handed to each relay's [fetchAllPages]
* measured from that relay's most recent message (every event resets it), not
* from the page's start. As in [fetchAllPages] there is no wall-clock ceiling;
* bound a relay's walk with a [Filter.limit], or cancel the caller.
* @param maxConcurrentRelays upper bound on relays paginating at once (≥ 1).
* @param onNewPage optional `(until, relay)` tick before each non-first page.
* @param onRelayStart optional hook fired as each relay's download begins.
@@ -60,7 +63,7 @@ import kotlinx.coroutines.sync.Semaphore
*/
suspend fun INostrClient.fetchAllPagesFromPool(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
maxConcurrentRelays: Int = 8,
onNewPage: ((until: Long, relay: NormalizedRelayUrl) -> Unit)? = null,
onRelayStart: ((relay: NormalizedRelayUrl) -> Unit)? = null,
@@ -80,7 +83,7 @@ suspend fun INostrClient.fetchAllPagesFromPool(
fetchAllPages(
relay = relay,
filters = filtersForRelay,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
onNewPage = onNewPage?.let { cb -> { until -> cb(until, relay) } },
) { event -> onEvent(event, relay) }
onRelayComplete?.invoke(relay, total)
@@ -41,13 +41,13 @@ import kotlinx.coroutines.withTimeoutOrNull
* funnel every arriving event through the suspending [onEvent] hook (verify /
* persist / filter — return `true` to keep it in the result), and return the
* accepted `(relay, event)` pairs once every relay reached a terminal state
* (EOSE, CLOSED, or cannot-connect) or the line went quiet for [timeoutMs].
* (EOSE, CLOSED, or cannot-connect) or the line went quiet for [idleTimeoutMs].
*
* [timeoutMs] is an **idle window, not a hard cap**: the clock only runs while
* [idleTimeoutMs] is an **idle window, not a hard cap**: the clock only runs while
* the relays are silent, and every arriving event or terminal signal resets
* it. A slow relay actively streaming a large backlog is therefore never
* cropped mid-delivery — the fetch ends when the work is done or when nothing
* has arrived for [timeoutMs] (a stall). The terminal conditions (EOSE /
* has arrived for [idleTimeoutMs] (a stall). The terminal conditions (EOSE /
* CLOSED / cannot-connect per relay) are what bound the fetch; the timeout's
* only job is detecting relays that will never reach one. [maxTotalMs]
* (default 10x the idle window) is the wall-clock ceiling that keeps a
@@ -62,20 +62,20 @@ import kotlinx.coroutines.withTimeoutOrNull
* as a hard failure via [classifyDrainFailure] (connect refused / DNS / TLS /
* dead HTTP upgrade — NOT slow relays or 429s) is recorded, so callers can
* prune proven-dead relays from future routing instead of paying the full
* [timeoutMs] on them again.
* [idleTimeoutMs] on them again.
* - **[pendingOnAuthRequired]** — a relay that refuses the REQ with an
* `auth-required:` CLOSED is kept pending rather than treated as terminal:
* the caller's NIP-42 responder answers the challenge and the client re-fires
* this same subscription, so the post-auth events are collected instead of
* returning empty. If auth never satisfies it, the relay simply falls through
* to the [timeoutMs].
* to the [idleTimeoutMs].
* - **[onTimeout]** — diagnostic hook fired when the idle window elapsed with
* relays still pending: receives the stalled set, the terminal reasons seen so
* far (`"eose"` / `"closed:<msg>"` / `"cannot:<msg>"`), and what was collected.
*/
suspend fun INostrClient.fetchAllWithHooks(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 8_000L,
idleTimeoutMs: Long = 8_000L,
subscriptionId: String = newSubId(),
pendingOnAuthRequired: Boolean = false,
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
@@ -86,9 +86,11 @@ suspend fun INostrClient.fetchAllWithHooks(
* adversarial or misbehaving relay could pin the caller forever. The cap
* restores an upper bound while staying far above the idle window, so a
* legitimately streaming relay still finishes its backlog. Pass
* [Long.MAX_VALUE] for a deliberately uncapped drain.
* [Long.MAX_VALUE] for a deliberately uncapped drain; a non-positive value
* also uncaps (absorbing an `idleTimeoutMs * 10` overflow from an
* effectively-infinite idle window).
*/
maxTotalMs: Long = timeoutMs * 10,
maxTotalMs: Long = idleTimeoutMs * 10,
onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean,
): List<Pair<NormalizedRelayUrl, Event>> {
if (filters.isEmpty()) return emptyList()
@@ -144,7 +146,7 @@ suspend fun INostrClient.fetchAllWithHooks(
coroutineScope {
subscribe(subscriptionId, filters, listener)
val watchdog =
if (maxTotalMs == Long.MAX_VALUE) {
if (maxTotalMs <= 0 || maxTotalMs == Long.MAX_VALUE) {
null
} else {
launch {
@@ -188,7 +190,7 @@ suspend fun INostrClient.fetchAllWithHooks(
// Slow path: both dry — arm one idle wait for the next signal.
if (pending == null) {
val progressed =
withTimeoutOrNull(timeoutMs) {
withTimeoutOrNull(idleTimeoutMs) {
select<Unit> {
eventChannel.onReceive { pending = it }
doneChannel.onReceive { (relay, reason) ->
@@ -254,7 +256,7 @@ suspend fun INostrClient.fetchAllWithHooks(
*/
suspend fun INostrClient.fetchAllPagesFromPoolWithHooks(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
maxConcurrentRelays: Int = 8,
onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean,
): List<Pair<NormalizedRelayUrl, Event>> {
@@ -285,7 +287,7 @@ suspend fun INostrClient.fetchAllPagesFromPoolWithHooks(
try {
fetchAllPagesFromPool(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
maxConcurrentRelays = maxConcurrentRelays,
) { event, relay -> eventChannel.trySend(relay to event) }
} finally {
@@ -29,8 +29,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext
suspend fun INostrClient.fetchFirst(
relay: String,
@@ -64,10 +66,29 @@ suspend fun INostrClient.fetchFirst(
filters: List<Filter>,
) = fetchFirst(subscriptionId, mapOf(relay to filters))
/**
* Subscribe [filters], return the first event any relay delivers (or `null` when
* every relay reached a terminal state — EOSE, CLOSED, or cannot-connect — with
* nothing matching, or the line went quiet).
*
* [idleTimeoutMs] is an **idle window measured from the most recent progress**, not a
* wall-clock deadline — the package-wide accessory convention. Progress means a
* signal that actually advances the fetch: an event, or the first terminal state
* from a relay still being waited on. Repeat chatter from a relay already
* accounted for (a CLOSED/reconnect loop) is *not* progress and does not restart
* the window — the same rule the negentropy watchdog applies to NOTICE/CLOSED
* error chatter, and what keeps a flapping relay from holding this open forever.
*
* That makes the call self-bounding: at most one progress signal per relay, each
* granting a fresh window. There is deliberately no ceiling parameter — a caller
* who wants a hard wall-clock bound already has one in
* `withTimeoutOrNull(ms) { fetchFirst(...) }`, which costs nothing here since a
* timed-out fetch returns `null` either way.
*/
suspend fun INostrClient.fetchFirst(
subscriptionId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
): Event? {
val eventChannel = Channel<Event>(UNLIMITED)
val doneChannel = Channel<NormalizedRelayUrl>(UNLIMITED)
@@ -112,29 +133,55 @@ suspend fun INostrClient.fetchFirst(
try {
subscribe(subscriptionId, filters, listener)
withTimeoutOrNull(timeoutMs) {
while (remaining.isNotEmpty()) {
select {
eventChannel.onReceive { event ->
result = event
remaining.clear()
}
doneChannel.onReceive { relay ->
// A relay sends its matching events before its EOSE, so an event may
// already be buffered when this completion fires. select() picks a ready
// clause at random, so without this drain we could treat the relay as done
// and exit while its event still sits unread in the channel.
val buffered = eventChannel.tryReceive().getOrNull()
if (buffered != null) {
result = buffered
remaining.clear()
} else {
remaining.remove(relay)
}
// One idle window per unit of progress. The inner loop keeps consuming
// non-progress signals INSIDE the same window, so repeat chatter from an
// already-accounted-for relay cannot push the deadline out; only a real
// advance escapes to the outer loop and earns a fresh window.
while (remaining.isNotEmpty()) {
val progressed =
withTimeoutOrNull(idleTimeoutMs) {
while (true) {
// Cancellation (this window expiring, or the caller giving up)
// only lands at a suspension point, and select() completes
// without suspending while either channel has something
// buffered — so check explicitly rather than draining a
// backlog of chatter uninterruptibly.
coroutineContext.ensureActive()
val advanced =
select<Boolean> {
eventChannel.onReceive { event ->
result = event
remaining.clear()
true
}
doneChannel.onReceive { relay ->
// A relay sends its matching events before its EOSE, so an event may
// already be buffered when this completion fires. select() picks a ready
// clause at random, so without this drain we could treat the relay as done
// and exit while its event still sits unread in the channel.
val buffered = eventChannel.tryReceive().getOrNull()
if (buffered != null) {
result = buffered
remaining.clear()
true
} else {
// Only the FIRST terminal signal from a relay we are still
// waiting on advances the fetch; a repeat is chatter.
remaining.remove(relay)
}
}
}
if (advanced) break
}
true
}
}
if (progressed == null) break
}
// An event can land after the last terminal signal but before we
// unsubscribe; without this drain it would be dropped and the fetch
// would report "nothing found" while holding a match.
if (result == null) result = eventChannel.tryReceive().getOrNull()
} finally {
unsubscribe(subscriptionId)
eventChannel.close()
@@ -47,14 +47,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.decrementAndFetch
import kotlin.concurrent.atomics.incrementAndFetch
import kotlin.coroutines.coroutineContext
import kotlin.math.min
import kotlin.time.TimeSource
/**
* Outcome of a successful [negentropySync] run.
@@ -1180,52 +1178,4 @@ internal val KEEP_ALIVE_ID = "f".repeat(64)
* still honor "run until the socket drops".
*/
private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L
private const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L
/**
* Monotonic "last activity" marker for the idle watchdog. [bump] on every sign of
* life from the relay; [elapsedMs] reports the silence since the last bump.
*
* [bump] is on the per-event hot path (the connection listener bumps for every
* message the relay sends — millions during a large download), so it must not
* allocate: a single [start] mark is taken once (unboxed field) and each bump only
* writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads
* write, the driver coroutine reads — visibility is all we need, so a plain volatile
* Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event.
*/
private class IdleClock {
private val start = TimeSource.Monotonic.markNow()
@Volatile
private var lastNanos = 0L
fun bump() {
lastNanos = start.elapsedNow().inWholeNanoseconds
}
fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000
}
/**
* Receives the next item, giving up (returning `null`) only after [idleMs] elapse with
* no activity on [clock]. Because [clock] is bumped by *any* relay message — not just
* items on this channel — unrelated progress (e.g. download events arriving during a
* reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the
* watchdog: it waits until an item arrives (a disconnect is delivered as an item, so
* a dead socket still unblocks it).
*/
private suspend fun <T> Channel<T>.receiveWithinIdle(
clock: IdleClock,
idleMs: Long,
): T? {
if (idleMs <= 0) return receive()
while (true) {
val remaining = idleMs - clock.elapsedMs()
if (remaining <= 0) return null
val item = withTimeoutOrNull(remaining) { receive() }
if (item != null) return item
// Timed out with nothing on this channel. If other activity bumped the clock
// meanwhile, the next `remaining` is positive and we wait again; otherwise it
// is <= 0 on the next iteration and we give up.
}
}
internal const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L
@@ -12,14 +12,56 @@ count, negentropy sync/reconcile) already exists.
Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.<name>` (or
`...client.reqs.<name>` for the flow/subscribe helpers).
## Timeout convention
Every wait in this package is an **idle window measured from the relay's most recent
progress**, never a wall-clock deadline: real progress resets it, so an actively
streaming relay is never cut off mid-delivery — the operation only gives up after a
full window of silence. The shared primitives are in `IdleWatchdog.kt` (`IdleClock` +
`receiveWithinIdle`); use them in a new accessory.
**The parameter is named `idleTimeoutMs`, never `timeoutMs`** — the name is the
contract, so a caller can't mistake it for a deadline. The sole exception is
`publishAndConfirm`'s `timeoutInSeconds`, which genuinely *is* a fixed window (see
below); the differing name is the tell.
**Progress, not merely traffic.** A message that tells us nothing new — a relay
re-CLOSEing after we already recorded it as done, a duplicate COUNT — must not
restart the window, or a flapping relay keeps the call alive indefinitely. This is
the rule the negentropy watchdog already applies to `NOTICE`/`CLOSED` chatter, and
it is what makes `fetchFirst` and multi-relay `count` self-bounding: at most one
window per relay.
**No accessory takes a wall-clock ceiling parameter.** A hard bound composes at the
call site — `withTimeoutOrNull(ms) { fetchFirst(...) }` — so duplicating it in every
signature buys nothing. Prefer the idle window inside (the caller cannot implement
it; it needs the message stream) and the wall clock outside. Two consequences worth
knowing:
- `fetchAllPages` has no ceiling and could not usefully have one. A per-page cap
bounds a *page*, not the call: the loop reacts to a page ending by advancing the
cursor and issuing the next `REQ`, so an endless trickle is just re-paged (a
400 ms cap measured 8 `REQ`s and no return). It also makes truncation unsafe —
cutting a page mid-stream advances `until` to the oldest event received *so far*,
which only preserves the set if the relay streams strictly newest-first, which
NIP-01 recommends but does not require. Bound a paged download with the filter's
`limit`, or by cancelling.
- `fetchAll` / `fetchAllWithHooks` keep a pre-existing `maxTotalMs`, and it earns
its place: an endless *event* trickle there is genuine progress, so the call never
self-terminates, and the internal cap returns the events collected so far where an
external `withTimeoutOrNull` would discard them.
The write side is its own case: `publishAndConfirm`'s `timeoutInSeconds` is a fixed
window to collect the `OK`s — a bounded confirmation round-trip, not a stream.
## One-shot reads (subscribe → collect → return)
| Function | File | Use when |
| --- | --- | --- |
| `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or timeout. **No verify, no store** — just the events. |
| `fetchFirst(relay, filter, timeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). |
| `fetchAllPages(relay, filters, timeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. |
| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once, deduped across them. |
| `fetchAll(relay, filter, idleTimeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or a full idle window of silence. **No verify, no store** — just the events. |
| `fetchFirst(relay, filter, idleTimeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). |
| `fetchAllPages(relay, filters, idleTimeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. |
| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once. No cross-relay dedup — the `WithHooks` variant below dedups. |
| `fetchAllWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAll` with a suspending per-`(relay, event)` accept hook (verify+store as events arrive), per-relay terminal-reason tracking, optional dead-relay collection (`deadOut` + `classifyDrainFailure`), keep-pending-on-`auth-required` CLOSED (NIP-42 re-fire), and a timeout diagnostic hook. |
| `fetchAllPagesFromPoolWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAllPagesFromPool` with the same suspending accept hook, run single-threaded in one consumer; deduped across relays by `SeenIds` before the hook. |
@@ -42,7 +84,7 @@ Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.<name>` (
| Function | File | Use when |
| --- | --- | --- |
| `count(relay, filter, timeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). |
| `count(relay, filter, idleTimeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). |
| `countMerged(relays, filter, ...)` | `NostrClientCountExt` | Merged count across relays. |
## Negentropy (NIP-77)
@@ -72,9 +72,13 @@ class LimitsPolicy(
return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else ReqCmd(cmd.subId, clamped))
}
/**
* A COUNT is clamped by [RelayLimits.maxLimit] but NEVER given
* [RelayLimits.defaultLimit] — see [capLimits].
*/
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> {
subscriptionRejection(cmd.queryId, cmd.filters)?.let { return PolicyResult.Rejected(it) }
val clamped = clampLimits(cmd.filters)
val clamped = capLimits(cmd.filters)
return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else CountCmd(cmd.queryId, clamped))
}
@@ -106,6 +110,28 @@ class LimitsPolicy(
return filters.map { it.copy(limit = targetLimit(it.limit)) }
}
/**
* Cap what a COUNT asks for, without inventing a page size for it.
*
* `defaultLimit` answers "how many events should a REQ return when the
* client names no limit". A COUNT returns no events, so that question has
* no meaning for it — and applying the answer anyway turns every unbounded
* COUNT into `min(matches, defaultLimit)`.
*
* Silently: a relay holding 12,289,614 profiles replied `{"count":500}`,
* which is a plausible-looking number, so a client cannot tell it from the
* truth. The kinds that happened to fall under the default were correct,
* which is what made it survive.
*
* `maxLimit` still applies, because a client that explicitly asks to count
* at most N is asking a question this relay may bound.
*/
private fun capLimits(filters: List<Filter>): List<Filter> {
val max = limits.maxLimit ?: return filters
if (filters.none { it.limit != null && it.limit!! > max }) return filters
return filters.map { if (it.limit != null && it.limit!! > max) it.copy(limit = max) else it }
}
private fun targetLimit(current: Int?): Int? =
when {
current != null && limits.maxLimit != null && current > limits.maxLimit -> limits.maxLimit
@@ -89,7 +89,7 @@ class FetchAllIdleTimeoutTest {
val collected =
client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
timeoutMs = 300,
idleTimeoutMs = 300,
) { _, _ -> true }
feeder.join()
assertEquals(10, collected.size, "an actively streaming relay must never be cropped")
@@ -111,7 +111,7 @@ class FetchAllIdleTimeoutTest {
val collected =
client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
timeoutMs = 300,
idleTimeoutMs = 300,
onTimeout = { stalled, _, _ -> stalledRelays = stalled },
) { _, _ -> true }
assertEquals(2, collected.size, "events before the stall are kept")
@@ -136,7 +136,7 @@ class FetchAllIdleTimeoutTest {
val events =
client.fetchAll(
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
timeoutMs = 300,
idleTimeoutMs = 300,
)
feeder.join()
assertEquals(10, events.size, "fetchAll shares the idle-window semantics")
@@ -162,7 +162,7 @@ class FetchAllIdleTimeoutTest {
val collected =
client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
timeoutMs = 300,
idleTimeoutMs = 300,
maxTotalMs = 1_000,
onTimeout = { stalled, _, _ -> stalledRelays = stalled },
) { _, _ -> true }
@@ -186,7 +186,7 @@ class FetchAllIdleTimeoutTest {
val collected =
client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
timeoutMs = 300,
idleTimeoutMs = 300,
) { _, _ -> true }
assertEquals(1, collected.size)
assertTrue(currentTime - start < 300, "a terminal EOSE must not wait out the window")
@@ -0,0 +1,189 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Pins [fetchFirst]'s timeout to the package-wide convention: `idleTimeoutMs` is
* silence measured from the most recent *progress*, not an absolute deadline
* across the whole multi-relay wait. Repeat chatter from a relay already
* accounted for is not progress, which is what makes the call self-bounding
* without a ceiling parameter — a hard bound is the caller's `withTimeoutOrNull`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class FetchFirstIdleTimeoutTest {
/** Captures the subscription listener so the test can play the relays. */
private class ScriptedClient : INostrClient by EmptyNostrClient() {
var listener: SubscriptionListener? = null
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
this.listener = listener
}
}
private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com")
private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com")
private val relayC = RelayUrlNormalizer.normalize("wss://c.example.com")
private val relayD = RelayUrlNormalizer.normalize("wss://d.example.com")
private fun event(i: Int) =
Event(
id = i.toString(16).padStart(64, '0'),
pubKey = "f".repeat(64),
createdAt = i.toLong(),
kind = 1,
tags = emptyArray(),
content = "e$i",
sig = "0".repeat(128),
)
private fun filters(vararg relays: NormalizedRelayUrl) = relays.associateWith { listOf(Filter(kinds = listOf(1))) }
@Test
fun genuineProgressRestartsTheIdleWindow() =
runTest {
val client = ScriptedClient()
launch {
// Each relay's FIRST terminal signal is real progress and buys a
// fresh window, carrying the fetch well past a single 300ms window
// so the slow relay's event at 900ms still lands. An absolute
// deadline would have returned null at 300ms.
delay(250)
client.listener!!.onClosed("rate limited", relayA, null)
delay(250)
client.listener!!.onClosed("rate limited", relayB, null)
delay(250)
client.listener!!.onClosed("rate limited", relayC, null)
delay(150)
client.listener!!.onEvent(event(1), false, relayD, null)
}
val result =
client.fetchFirst(
filters = filters(relayA, relayB, relayC, relayD),
idleTimeoutMs = 300,
)
assertEquals(event(1).id, result?.id, "progress must restart the window; the slow relay's event still lands")
}
@Test
fun totalSilenceReturnsNullAfterOneIdleWindow() =
runTest {
val client = ScriptedClient()
val start = currentTime
val result =
client.fetchFirst(
filters = filters(relayA),
idleTimeoutMs = 300,
)
assertNull(result)
assertEquals(300L, currentTime - start, "a silent relay costs exactly one idle window")
}
@Test
fun repeatTerminalChatterDoesNotRestartTheIdleWindow() =
runTest {
val client = ScriptedClient()
val chatter =
launch {
// relayA re-CLOSEs forever (a reconnect loop); relayB never
// answers. Only relayA's FIRST CLOSED is progress — it removes
// relayA from `remaining`. The repeats say nothing new, so they
// must not push the deadline out (the rule the negentropy
// watchdog already uses for NOTICE/CLOSED chatter).
while (true) {
delay(200)
client.listener!!.onClosed("auth-required: again", relayA, null)
}
}
val start = currentTime
val result =
client.fetchFirst(
filters = filters(relayA, relayB),
idleTimeoutMs = 300,
)
chatter.cancel()
assertNull(result)
// First CLOSED at 200ms is the only progress; the window then expires
// 300ms later despite chatter at 400/600/800…
assertEquals(500L, currentTime - start, "repeat chatter must not keep the wait alive")
}
@Test
fun anEventArrivingAfterTheLastTerminalSignalIsStillReturned() =
runTest {
val client = ScriptedClient()
launch {
delay(100)
// The only relay EOSEs, emptying `remaining` and ending the loop —
// then its matching event lands before we unsubscribe. Without the
// post-loop drain this returns null while holding a match.
client.listener!!.onEose(relayA, null)
client.listener!!.onEvent(event(7), false, relayA, null)
}
val result =
client.fetchFirst(
filters = filters(relayA),
idleTimeoutMs = 300,
)
assertEquals(event(7).id, result?.id, "an event racing the final EOSE must not be dropped")
}
@Test
fun aHardWallClockBoundIsTheCallersToApply() =
runTest {
val client = ScriptedClient()
val chatter =
launch {
while (true) {
delay(50)
client.listener!!.onClosed("flapping", relayA, null)
}
}
// No ceiling parameter: composing withTimeoutOrNull at the call site
// is the wall-clock bound, and costs nothing because a timed-out
// fetchFirst yields null either way.
val start = currentTime
val result = withTimeoutOrNull(120) { client.fetchFirst(filters = filters(relayA, relayB), idleTimeoutMs = 10_000) }
chatter.cancel()
assertNull(result)
assertEquals(120L, currentTime - start, "the caller's timeout bounds the call")
}
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RelayLimitsTest {
@@ -157,6 +158,39 @@ class RelayLimitsTest {
)
}
@Test
fun countNeverGetsTheDefaultLimit() {
// A COUNT returns no events, so "how many should a REQ return by
// default" is not a question it asked. Applying the answer anyway turns
// every unbounded COUNT into min(matches, defaultLimit) — a relay
// holding 12,289,614 profiles replied {"count":500}, which is plausible
// enough that no client can tell it from the truth.
val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100))
val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1))))) as PolicyResult.Accepted
assertNull(
result.cmd.filters
.single()
.limit,
)
}
@Test
fun countStillHonoursAnExplicitMaxLimit() {
// Asking to count at most N is a question the relay may bound.
val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100))
val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1), limit = 900)))) as PolicyResult.Accepted
assertEquals(
100,
result.cmd.filters
.single()
.limit,
)
}
@Test
fun leavesAcceptableRequestUnchanged() {
val policy = LimitsPolicy(RelayLimits(maxLimit = 100))
@@ -0,0 +1,190 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.TimeSource
/**
* Pins [fetchAllPages]'s timeout to the package-wide idle-window convention:
* `idleTimeoutMs` is silence measured from the relay's MOST RECENT message, not a
* wall-clock deadline for the page. A relay that keeps streaming — however
* slowly — must never have a page cropped mid-delivery.
*
* Real-clock ([runBlocking]) on purpose: the page watchdog is a monotonic
* `IdleClock` bumped from the socket reader thread, which virtual time can't
* exercise.
*/
class NostrClientFetchAllPagesIdleTimeoutTest {
/** Captures the subscription listener so the test can play a relay. */
private class ScriptedClient : INostrClient by EmptyNostrClient() {
@Volatile
var listener: SubscriptionListener? = null
@Volatile
var subscribeCount = 0
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
subscribeCount++
this.listener = listener
}
}
private val relay = RelayUrlNormalizer.normalize("wss://slow.example.com")
private fun event(
i: Int,
createdAt: Long = i.toLong(),
) = Event(
id = i.toString(16).padStart(64, '0'),
pubKey = "f".repeat(64),
createdAt = createdAt,
kind = 1,
tags = emptyArray(),
content = "e$i",
sig = "0".repeat(128),
)
@Test
fun streamingPageOutlivesTheIdleWindow() =
runBlocking {
val client = ScriptedClient()
val feeder =
launch {
// 6 events, each arriving 150ms apart — every gap is under the
// 500ms idle window, but the whole page (~950ms) far exceeds it.
// The old hard per-page deadline truncated this mid-stream and
// re-subscribed for another page; the idle window must not.
repeat(6) { i ->
delay(150)
client.listener!!.onEvent(event(i + 1), false, relay, null)
}
delay(50)
client.listener!!.onEose(relay, null)
}
var pages = 0
val got = mutableListOf<Event>()
val total =
client.fetchAllPages(
relay = relay,
filters = listOf(Filter(kinds = listOf(1), limit = 6)),
idleTimeoutMs = 500,
onNewPage = { pages++ },
) { got.add(it) }
feeder.join()
assertEquals(6, total, "a slowly-but-actively streaming page must never be cropped")
assertEquals(6, got.size)
assertEquals(1, client.subscribeCount, "the whole stream must arrive in ONE page — a hard deadline would truncate and re-subscribe")
assertEquals(0, pages, "no pagination should be needed")
}
@Test
fun silentRelayGivesUpOneIdleWindowAfterItsLastMessage() =
runBlocking {
val client = ScriptedClient()
val feeder =
launch {
// Two quick events, then the relay goes quiet without EOSE: the
// page must end ~one idle window after the LAST message.
delay(50)
client.listener!!.onEvent(event(1), false, relay, null)
delay(50)
client.listener!!.onEvent(event(2), false, relay, null)
}
val start = TimeSource.Monotonic.markNow()
val got = mutableListOf<Event>()
// limit = 3 keeps the filter unfulfilled, so only the stall can end the
// page; the events already delivered are kept and, since nothing older
// followed, the next page comes back empty and the walk terminates.
val total =
client.fetchAllPages(
relay = relay,
filters = listOf(Filter(kinds = listOf(1), limit = 3)),
idleTimeoutMs = 300,
) { got.add(it) }
feeder.join()
val elapsedMs = start.elapsedNow().inWholeMilliseconds
assertEquals(2, total, "events delivered before the stall are kept")
assertTrue(elapsedMs >= 300, "must wait out at least one idle window, took ${elapsedMs}ms")
assertTrue(elapsedMs < 5_000, "a stalled page must end promptly after the idle window, took ${elapsedMs}ms")
}
/**
* Documents why [fetchAllPages] has no wall-clock ceiling — nor should any
* accessory: a hard bound composes at the call site as `withTimeoutOrNull`.
*
* A per-page ceiling cannot bound this walk: when a page ends, the loop advances
* the cursor and fires the NEXT `REQ`, so an endless trickle against an unbounded
* filter is merely re-paged. This pins that reality — the walk runs until the
* caller cancels — so nobody re-adds a `maxPageMs` believing it caps anything.
* What actually bounds a download is the filter's `limit`.
*/
@Test
fun anEndlessTrickleIsBoundedByCancellationNotByAWallClock() =
runBlocking {
val client = ScriptedClient()
var ts = 10_000_000L
var i = 1
val feeder =
launch {
// Trickles forever, strictly decreasing created_at, never EOSE.
while (true) {
delay(40)
client.listener?.onEvent(event(i++, ts--), false, relay, null)
}
}
val returned =
withTimeoutOrNull(1_500) {
client.fetchAllPages(
relay = relay,
filters = listOf(Filter(kinds = listOf(1))), // unbounded: no limit
idleTimeoutMs = 200,
) { }
}
feeder.cancel()
assertNull(returned, "an unbounded filter against an endless trickle ends only by cancellation")
}
}
@@ -141,7 +141,7 @@ class BulkDownloadBenchmark {
client.fetchAllPages(
relay = relayUrl,
filters = listOf(Filter(kinds = listOf(1), since = lo, until = hi)),
timeoutMs = LOCAL_PAGE_TIMEOUT_MS,
idleTimeoutMs = LOCAL_PAGE_TIMEOUT_MS,
) { event ->
count.incrementAndGet()
bytes.addAndGet(event.content.length.toLong())
@@ -376,7 +376,7 @@ class BulkDownloadBenchmark {
client.fetchAllPages(
relay = relay,
filters = listOf(Filter(kinds = listOf(PROD_KIND), limit = PROD_MAX_EVENTS)),
timeoutMs = 30_000L,
idleTimeoutMs = 30_000L,
onNewPage = { pages++ },
) { event ->
count.incrementAndGet()
@@ -672,7 +672,7 @@ class BulkDownloadBenchmark {
client.fetchAllPages(
relay = relay,
filters = listOf(Filter(kinds = listOf(PROD_KIND), limit = PROD_MAX_EVENTS)),
timeoutMs = 30_000L,
idleTimeoutMs = 30_000L,
onNewPage = { pages++ },
) { event ->
count.incrementAndGet()
@@ -280,7 +280,7 @@ class ByIdFetchBenchmark {
client.fetchAllPages(
relay = relay,
filters = listOf(Filter(kinds = listOf(KIND))),
timeoutMs = ENUM_PAGE_TIMEOUT_MS,
idleTimeoutMs = ENUM_PAGE_TIMEOUT_MS,
) { event -> ids.add(event.id) }
println(" enumerated ${ids.size} ids in %.1fs (paged, untimed baseline for the matrix)".format((System.nanoTime() - t) / 1e9))
ids.toList()