fix(quartz): audit fixes — foreign-OK confirmation bug, normalizer hot-path allocation

Audit findings across the branch, each verified with a failing test or
measurement before the fix:

- publishAndCollectResults counted an OK from a relay OUTSIDE relayList
  (same event id — a probe-wave straggler, or any republish of the same
  event to a different relay set) toward its confirmation window, ending
  the wait loop early and misreporting still-pending listed relays as
  NO_RESPONSE. The OK branch now carries the same relayList guard the
  onCannotConnect/onDisconnected branches always had. Regression test
  proves the failure without the guard. readWriteCheck additionally
  varies the probe event content per wave so wave N's confirmation window
  can never match wave N-1's event id at all.

- RelayUrlNormalizer.fix() called trimEnd('%','2','0') unconditionally,
  allocating a full string copy for ANY url merely ending in '%', '2' or
  '0' — which includes every relay port ending in zero (wss://host:3030).
  Now gated on endsWith("%20"), keeping the hot path allocation-free;
  semantics unchanged (test pins both the trim and the untouched-port
  cases).

- amy relay probe --file: unreadable file is now a clean bad_args error
  instead of a stack trace, and skipped onion urls are counted and
  reported (file_onion_skipped) instead of vanishing from the tally.

- probeFlow KDoc now states that a slow collector eats into the current
  wave's absolute deadline (answers are still recorded; silent relays get
  less listening time), not just that it delays the next wave.

Verified non-issues: androidx.collection LruCache is internally locked
(safe for CachedNip11Fetcher/normalizer concurrency); probeWave's
per-terminal emission cannot lose or double-emit verdicts (remaining-set
guard, data maps read at emission time); existing publish callers all
benefit from the OK guard rather than depending on the old behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
This commit is contained in:
Claude
2026-08-04 15:14:17 +00:00
parent fd5bd994a1
commit c767298368
6 changed files with 70 additions and 14 deletions
@@ -349,21 +349,26 @@ object RelayCommands {
var fileRaw = 0
var fileRejected = 0
var fileOnion = 0
val fileRelays = HashSet<NormalizedRelayUrl>()
if (fromFile != null) {
File(fromFile).forEachLine { line ->
val candidates = File(fromFile)
if (!candidates.canRead()) return Output.error("bad_args", "cannot read --file $fromFile")
candidates.forEachLine { line ->
if (line.isBlank()) return@forEachLine
fileRaw++
val normalized = line.normalizeRelayUrlOrNull()
if (normalized == null) {
fileRejected++
} else if (!RelayUrlNormalizer.isOnion(normalized.url)) {
} else if (RelayUrlNormalizer.isOnion(normalized.url)) {
fileOnion++
} else {
fileRelays.add(normalized)
}
}
System.err.println(
"[relay-probe] $fromFile: $fileRaw urls → ${fileRelays.size} unique clearnet relays " +
"($fileRejected rejected by the normalizer)",
"($fileRejected rejected by the normalizer, $fileOnion onion skipped)",
)
}
@@ -412,6 +417,7 @@ object RelayCommands {
"file_urls" to (if (fromFile != null) fileRaw else null),
"file_normalized" to (if (fromFile != null) fileRelays.size else null),
"file_rejected" to (if (fromFile != null) fileRejected else null),
"file_onion_skipped" to (if (fromFile != null) fileOnion else null),
"reachable" to result.reachable.size,
"dead" to result.dead.size,
"closed_by_policy" to authWalled,