The bridge was emitting `xs=https://cdn.nostr.build` for URLs like
`https://cdn.nostr.build/i/<sha>.jpg`, dropping the `/i/` prefix. The
local cache appends `/<sha>` to the xs server hint per spec, so it would
try to fetch `https://cdn.nostr.build/<sha>` — a 404 on nostr.build's
non-Blossom CDN scheme.
Anchor the server-base extraction on the slash immediately preceding the
sha-bearing path segment so we emit `xs=https://cdn.nostr.build/i`.
The cache then appends `/<sha>` and reaches the original blob.
Affects all three bridge entry points:
- MediaUrlContent.toCoilModel (note media)
- bridgeProfilePictureUrl (profile pictures)
- LocalBlossomCacheRedirectInterceptor (everything else via OkHttp)
Flat Blossom paths (`<host>/<sha>`) keep emitting `xs=<host>` unchanged.
bridgeProfilePictureUrl previously returned a `blossom:` URI, but
profile pictures go through ProfilePictureFetcher (Coil routes by type
on `ProfilePictureUrl`), which feeds the URL straight to NetworkFetcher
and never sees BlossomFetcher. NetworkFetcher then tries to issue an
HTTP request against the `blossom:` scheme and fails — silently — so
profile pictures from Blossom servers stopped loading.
Return a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pub>`
URL instead. The OkHttp request goes to the local cache normally; the
new redirect interceptor sees it's already on localhost and passes
through unchanged.
Pictures hosted on Blossom servers (URLs with a 64-char sha256 hex in
the path) bypassed the bridge whenever they were rendered through a
composable that wasn't explicitly updated to call toCoilModel — link
previews, video thumbnails, badge images, audio room covers, etc.
Add LocalBlossomCacheRedirectInterceptor as an app-level OkHttp
interceptor that catches every HTTP request transparently:
- Detects a 64-char hex segment in any path segment
- Rewrites the request URL to http://127.0.0.1:24242/<sha>.<ext>?xs=<host>
- Coil's disk cache continues to key by the original URL so cached
blobs survive toggling the bridge off
Activates only when the master toggle is on, the profile-pictures-only
restriction is off, and the probe sees localhost up. Profile-only mode
still routes profile pics via the existing composable bridge.
Aligns the toggle with its Security Filters siblings (warnAboutPostsWithReports,
filterSpamFromStrangers, etc.): stored in AccountSecurityPreferences{Internal},
synced across devices via the app-specific data event, and updated through
account.updateDisableClientTag -> sendNewAppSpecificData.
The signer wrapper now reads the live value from synced settings, so changes
still apply immediately to the next signed event.
https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS
- `as=<authorPubKey>`: when converting plain http(s) imeta URLs into
`blossom:` URIs we now include the note author's pubkey, letting the
local cache consult their kind:10063 BUD-03 server list on miss.
Plumbed via RichTextParser/CachedRichTextParser/RichTextViewer/
ExpandableRichTextViewer/TranslatableRichTextViewer.
- Profile-pictures-only mode: a new per-account toggle that restricts
the bridge to profile pictures (RobohashFallbackAsyncImage). When on,
feed images and videos skip the bridge entirely. Profile pictures
recover their sha256 from the URL path when present (covers
Blossom-hosted avatars like https://nostr.build/i/<sha>.jpg).
Adds a "Don't add client tag to my events" switch under Security Filters.
NostrSignerWithClientTag now consults a runtime predicate at sign-time, so
the toggle takes effect immediately on the live session without rewrapping
the signer or rebuilding Account.
https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS
Several user-visible error messages on Desktop are rendered with plain
`Text` composables, which means they can't be selected or copied. That
makes it awkward to share an error in a bug report or paste a hex error
string into a search.
Wrap the error text in `SelectionContainer` at the canonical sites:
- `commons.ui.components.LoadingState`: wrap the description in
`EmptyState` and the message in `ErrorState`. `EmptyState` is reused
as the in-feed error renderer (e.g. 'Error loading feed' with the
underlying error in `description`), so this covers feed/loading
errors across screens that use these helpers.
- `ComposeNoteDialog`: wrap the validation error and the upload error
in the compose-note dialog.
- `auth/LoginCard` (Nostr Connect): wrap the connection error.
- `auth/KeyInputField`: wrap the supporting-text error so the inline
message under the nsec input field can be copied.
No visual changes \u2014 `SelectionContainer` does not affect layout or
styling. Selection works on Compose Desktop (mouse drag) and on Android
(long-press) without further changes.
Replace manual relay subscribe + Channel approach with the proven
SearchBarState + rememberSubscription pattern (same as NewDmDialog).
This properly handles relay connection lifecycle and NIP-50 search,
returning results from all connected relays.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hoist author search state to FeedsDrawerTab (AlertDialog can't run LaunchedEffect)
- NIP-50 relay search via connected relays with Channel-based result streaming
- 28dp UserAvatar in author suggestion rows
- "No users found" empty state
- 32dp dialog margins, 300dp max results height, 30 result limit
- FindUsersTest: 8 tests verifying cache search with/without metadata
- Fix: use connectedRelays instead of unconnected DEFAULT_SEARCH_RELAYS
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Brazilian Portuguese's 'one' quantity matches both 0 and 1, so a
hardcoded '1' is wrong when the count is 0. Switch the 'one' branch
of scheduled_posts_subtitle_due_suffix and scheduled_posts_relay_count
to use the %d argument like the 'other' branch.
https://claude.ai/code/session_01UmYZfFy8QqaDaXbcXguu8f
Lint flagged the notify() call as MissingPermission because there was no
visible check that POST_NOTIFICATIONS was granted. Gate the call behind
areNotificationsEnabled() — matching the pattern used in
EventNotificationConsumer — and catch SecurityException for the
narrow window where permission could be revoked between the check and
the notify().
The mid-stream resub pattern (resub1 at events.size==1, resub2 at
events.size==5) has four receive() calls between the two subscribe
calls. On a slow CI worker that's enough time for resub1
(filtersShouldIgnore, kind 10002 limit 500) to actually reach the
relay and start streaming events before resub2 replaces it, so the
loop's second EOSE can come from filtersShouldIgnore — which can
deliver up to 50 events (the preloaded count for that kind), well
past the previous 1..11 bound.
Drop the phase-specific count asserts and verify only structural
invariants: exactly two EOSEs, the loop stopped on the second, every
non-EOSE entry is a valid 64-char id, and the total event count
stays within the worst-case envelope.
https://claude.ai/code/session_01L4qS7BhX3L7ApiS3HsCozn
Three small follow-ups from the audit pass:
1. Drop redundant `challengeData.copyOf()` in
`queuePathResponseLocked` — the parser produces a fresh
ByteArray per PATH_CHALLENGE via `QuicReader.readBytes`'s
`copyOfRange`, so the defensive copy was a wasted allocation.
One-line fix.
2. Rename `pendingPathResponses` → `pendingPathChallengePayloads`.
The queue holds inbound CHALLENGE payloads we owe RESPONSES
for — old name conflated the two. Pure rename across
QuicConnection / Parser / Writer / PathValidationTest.
3. Extract shared `newConnectedClient(...)` test fixture
(`ConnectedClientFixture.kt`). The 6 test files each repeated
~40 lines of identical handshake-pipe boilerplate; folded into
one parameterized helper accepting transport-cap overrides.
Net −164 lines across the test tree; per-test helper is now a
one-liner that documents the cap shape.
No behavior change. Full quic suite + amethyst hook test green.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Conflicts:
- quartz/.../LiveEventStore.kt: main added IngestQueue (group-commit)
and a fire-and-forget submit() path; this branch replaced the
SharedFlow live fanout with FilterIndex-driven dispatch. Resolved
by keeping main's submit/insert pipeline shape (IngestQueue ingest
ctor param, submit() callback, insert() wrapping submit via
CompletableDeferred) but routing the on-Accepted fanout through
FilterIndex.candidatesFor instead of newEventStream.tryEmit.
Added private fanout(event) helper. Kept main's
snapshotIdsForNegentropy method.
- geode/.../LoadBenchmark.kt: both sides added a new @Test method.
Kept fanoutScaling (this branch) and publishPipelinedSingleClient
(main).
Soak target #4 — minimum viable path validation. Lands the
spec-required peer-initiated case so a server probing the path
(e.g. after a NAT rebind, or post-CID rotation) sees a matching
PATH_RESPONSE and doesn't declare the path dead.
Pre-fix the parser decoded PATH_CHALLENGE / PATH_RESPONSE bytes
but threw the result away — a peer's challenge went silently
into the void. After ~3 RTT of no response, a strict peer would
mark the path dead and tear the connection down (visible to
audio-rooms users as a sudden cut on a phone that briefly
switched cells).
Implementation:
- Add PathChallengeFrame / PathResponseFrame data classes;
wire decode and encode (was decode-and-discard previously).
- Add `pendingPathResponses` queue on QuicConnection (bounded at
MAX_PENDING_PATH_RESPONSES = 64 to defend against challenge
flood; excess silently dropped — peer retries on PTO).
- Parser handler queues a response on inbound PATH_CHALLENGE.
- Writer drains the queue in buildApplicationPacket. RFC 9000
§13.3 doesn't list PATH_RESPONSE as ack-eliciting-and-
retransmittable; if a response is lost, the peer's next
PATH_CHALLENGE re-queues it and we respond again.
Out of scope for this landing (multi-day each, parked unless
production evidence requires):
- Client-initiated migration: requires UdpSocket replacement,
new-CID acquisition tracking, validating new path BEFORE
moving traffic to it.
- Anti-amplification on unvalidated paths (RFC 9000 §8.1).
Tests (PathValidationTest, 6 cases):
- PATH_CHALLENGE / PATH_RESPONSE codec round-trip + 8-byte
length validation.
- End-to-end: peer PATH_CHALLENGE → client PATH_RESPONSE
with byte-equal payload.
- Multi-challenge fan-in: 3 challenges → 3 distinct responses
(in any order; matched by content).
- Flood cap: 256 challenges → ≤ MAX_PENDING_PATH_RESPONSES
responses, connection stays CONNECTED.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Working through the punch list from the prior "what's left?" status.
#2 close-under-load (CloseUnderLoadTest, 3 cases)
==================================================
Pins three races between connection close and active stream
traffic that the existing idle-driver close test doesn't cover:
- closeWhileBulkStreamRetirementIsRunning — server ACKs 100
in-flight client-bidi streams in one shot, retire pass + close
fire concurrently. Asserts CLOSED status and no Flow leak.
- closeWhileAppCoroutinesAreOpeningStreamsDoesNotDeadlock —
pins the lock-ordering invariant: streamsLock (openers) and
lifecycleLock (close) don't fight.
- closeWhilePeerStreamsAreInFlight — close fires mid-stream of
50 server-uni group streams (half FIN'd, half not). Every
incoming Flow terminates promptly with whatever bytes the
parser had already delivered.
#3 PN-gate / try-previous-fall-through-to-next for key updates
==============================================================
Closes the KNOWN-LIMITATION I documented in the prior round.
QuicConnectionParser previously routed mismatched-KEY_PHASE
packets unconditionally to previousReceiveProtection if non-null,
which silently dropped consecutive-rotation packets (KEY_PHASE
wraps back to its prior value, prior keys are now wrong, AEAD
fails, connection wedges).
Fix follows neqo's shape: try previous keys; on AEAD failure fall
through to next-phase derivation. Two AEAD attempts on a
mismatched-phase packet are cheap; KEY_PHASE mismatch is rare.
The previously-disabled twoConsecutiveRotationsCommitCorrectly
test now passes.
#4 loss harness depth (MoqLiteLossHarnessTest, 3 added cases)
=============================================================
First-pass harness from the previous round was a single 5%-loss
moq-lite shape. Added:
- listenerToleratesPacketReorderingOnGroupStreams — random
permutation of 50 group-stream datagrams, asserts 100%
delivery. Pins the reorder contract for moq-lite.
- listenerSurvivesExtremeTwentyPercentLoss — 200 streams at
20% loss, asserts ≥ 60% delivery and connection stays
CONNECTED. Catches catastrophic-collapse regressions in
flow-control / ACK-tracker / retired-id ring under stress.
- reliableBidiStreamRecoversFromMidStreamPacketLoss — drops
the middle two of four STREAM frames on a reliable bidi
stream, retransmits, asserts the consumer surfaces the full
contiguous payload. Pins the reliability contract distinct
from the best-effort moq-lite path.
#1 foreground-resume recycle (AppForegroundRecycleHook, 5 tests)
================================================================
Closes the user-visible production gap. ReconnectingNestsListener
already orchestrates retry on terminal state and observes
NestNetworkChangeBus for network-handover recycles. The missing
piece was a foregrounding signal: when Android reclaims the app's
UDP socket FD after backgrounding (typical at ~30 s+, network
itself still up so the connectivity callback doesn't fire), the
QUIC connection sits dead until the next send-loop throw — which
landed last round.
This hook publishes a NestNetworkChangeBus event when the app
returns to foreground after spending ≥ 5 s in background. The
pre-existing wiring observes that event and calls
recycleSession() on every active listener / speaker. Pure-state
core (AppForegroundCounter) is testable without Robolectric;
JUnit-4 unit tests pin the threshold logic, multi-activity
counter behaviour (e.g. PIP), and consecutive-cycle correctness.
Wired into Amethyst.Application.onCreate via
registerActivityLifecycleCallbacks.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
#2 KEY UPDATE VERIFICATION (soak target #2)
Added KeyUpdatePeerInitiatedTest pinning the RFC 9001 §6 peer-initiated
1-RTT key update path against InMemoryQuicPipe:
- peerInitiatedRotationCommitsAndMirrorsOnSend — single rotation
flips currentReceiveKeyPhase, mirrors currentSendKeyPhase, retains
pre-rotation keys as previousReceiveProtection, installs new
receive+send protections; connection stays CONNECTED.
- reorderedPacketOnPriorKeysStillDecryptsAfterRotation — packet
sent before peer rotated but arriving after the rotation
triggering packet decrypts via previousReceiveProtection (RFC
9001 §6.1 reorder window).
- postRotationOutboundPacketCarriesNewKeyPhaseAndDecryptsForPeer —
writer stamps currentSendKeyPhase into the short header AND
encrypts with the rolled-forward send keys.
Test infrastructure: InMemoryQuicPipe grows rotateServerApplicationKeys
(walks the same HKDF-Expand-Label "quic ku" dance the production peer
would) plus buildServerApplicationDatagramWithPriorKeys (re-emits via
the stashed pre-rotation TX, exercising the reorder-window path).
Documented limitation: consecutive rotations within the reorder window
mis-route via previousReceiveProtection. The spec-correct fix is to
gate previousReceiveProtection on a packet-number threshold (neqo /
picoquic shape); for the audio-rooms 3-hour scenario, a single
rotation is the realistic case so this is a follow-on rather than a
blocker. No test asserts the broken behaviour.
#3 RECONNECT-ON-FOREGROUND (soak target #3)
ReconnectingNestsListener already has all the orchestration
(exponential-backoff retry, JWT-refresh recycle, recycleSession()
hook for platform network-change events). What was missing at the
QUIC level: when the OS reclaims the UDP socket FD while the app is
backgrounded, socket.send() throws and the bare exception escapes
the SupervisorJob silently. The connection sits in HANDSHAKING /
CONNECTED indefinitely and the orchestrator's terminal-state
listener never fires — the room screen shows "live" while audio
is dead.
Wrapped sendLoop in try/catch mirroring the existing readLoop's
finally block: any uncaught Throwable (CancellationException
excepted, since close() is already driving teardown) calls
markClosedExternally with the cause. Also wired markClosedExternally
to record closeReason on first-call so observability surfaces the
human-readable cause through to NestsListenerState.Failed.reason.
Pinned by socketDeathMidSessionFlipsConnectionToClosed —
runs the driver, tears the UDP socket out from under it, asserts
status flips to CLOSED within 5 s and the close reason mentions the
loop death. Pre-fix this would loop forever waiting for status to
move.
Tests:
- KeyUpdatePeerInitiatedTest (3 cases)
- QuicConnectionDriverLifecycleTest::socketDeathMidSessionFlipsConnectionToClosed
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Follow-up to b8c6e080 addressing the gaps I called out in the
"is this the best we can do?" reply.
1. **Heap-sampling soak test** (`QuicHeapSoakTest`). Long-form, default-
skipped via `-PquicSoakSeconds=N` propagated by `quic/build.gradle.kts`
to the jvmTest task. Without the property the test early-returns with
a printed SKIPPED line, so `./gradlew test` stays fast for CI. With
the property, drives moq-lite-shaped peer-uni churn at ~50 streams/s
for N seconds, samples `totalMemory - freeMemory` six times across
the run, and fails if the post-warmup → final delta exceeds 10 MB
(the acceptance threshold from the audio-rooms soak prompt).
Production use: `-PquicSoakSeconds=1800` for the 30-minute soak.
2. **FD-leak canary** added to `QuicConnectionDriverLifecycleTest`. On
Linux, samples `/proc/self/fd` size before / after the 100-session
loop; banded at +16 entries for ambient JVM noise. macOS / Windows
silently no-op because /proc isn't there. Catches socket / pipe
leaks the thread-count check would miss.
3. **Phantom-stream guard.** Added `retiredStreamIdSet` (capped FIFO
ring at 4 096 entries, ~80 s of moq-lite churn) plus
`isStreamIdRetiredLocked` on the connection. Parser checks before
`getOrCreatePeerStreamLocked` and drops STREAM frames the peer
retransmits on already-retired streams. Eliminates the
"duplicate ACK lost → peer retransmits FIN → we mint a phantom
QuicStream" edge case I papered over in the previous commit.
Pinned by `phantomGuardDropsRetransmitOnRetiredPeerStream`.
4. **moq-lite loss harness** (`MoqLiteLossHarnessTest`). First pass at
soak target #5: drive 50 best-effort group streams with 5%
uniform packet loss, assert the listener surfaces ≥ 90% with
payloads intact and the connection stays CONNECTED. Out of scope
here: reorder injection, latency-under-loss measurement, full
end-to-end with a real moq-lite publisher.
Tests:
- `QuicHeapSoakTest` — gated, validates 10MB heap acceptance band.
- `QuicConnectionDriverLifecycleTest::repeatedSessionLifecycleDoesNotLeakThreads`
— now also enforces /proc/self/fd bound.
- `StreamRetirementSoakTest::phantomGuardDropsRetransmitOnRetiredPeerStream`
— pins the duplicate-frame drop semantics.
- `MoqLiteLossHarnessTest` — 2 cases (lossy + lossRate=0 baseline).
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Soak target #1 from the audio-rooms hardening pass: moq-lite over QUIC
mints one peer-uni stream per Opus frame, so a 3-hour broadcast at
~50 frames/sec accumulated ~540 000 stream entries in
`QuicConnection.streamsList` / `streams` for the lifetime of the
session. The two structures were append-only — closed streams were
filtered out of the writer's iteration but never removed — and the
heap grew monotonically.
Adds `QuicStream.isFullyRetired` plus `retireFullyDoneStreamsLocked`
on the connection. The writer drains the retire pass at the top of
`buildApplicationPacket`, dropping streams whose send side has
peer-acked FIN/RESET and whose receive side has both FIN'd and
fully drained into the application's incoming Channel. The
cumulative receive high-water folds into `retiredStreamsRecvBytes`
so the connection-level MAX_DATA accounting in
`appendFlowControlUpdates` keeps advertising the lifetime total —
without that seed, retiring K bytes would silently regress the
peer's send credit.
Also adds soak target #6 coverage: `QuicConnectionDriver` now
exposes `driverJob` / `closeTeardownJob` for test assertion, and
the new `QuicConnectionDriverLifecycleTest` cycles 100 sessions
against a localhost UDP blackhole to pin idempotent close +
bounded thread growth.
Tests:
- `StreamRetirementSoakTest` (4 cases): local-uni FIN+ACK
retirement, peer-uni listener-path retirement, MAX_DATA accounting
preservation across retire, and a 10 000-stream churn harness
that asserts the working set stays bounded.
- `QuicConnectionDriverLifecycleTest` (2 cases): close idempotency
and 100-session thread-leak canary.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
- Delete `strfryDrivesGeodeAsServer` — boots strfry but uses
kmp-negentropy ↔ Geode, no actual strfry-vs-geode interop.
Duplicates `Nip77NegentropyTest.negentropyComputesSymmetricDifference`.
- Strip speculative `negentropy { enabled = ... }` and `nofiles`
blocks from the strfry config; defaults are what we want to test.
- `InteropSyncDriver.reconcile` → `negotiate`. The function
computes the symmetric difference; it doesn't move events.
Convert to `suspend fun` to drop a nested `runBlocking` that
could deadlock under dispatcher pressure.
- Inline `pullSync` helper in GeodeVsGeodeNegentropySyncTest —
it was a one-line wrapper with a misleading name.
- Batch `relayB.preload(needFromA)` instead of looping.
- Tighten `idsOnRelay(NormalizedRelayUrl)` signature (was
re-parsing the string on every call).
- Drop redundant `assertTrue(size > cap)` after `assertEquals(11)`.
New geode/src/test/kotlin/com/vitorpamplona/geode/interop/ folder
mirrors strfry's test/syncTest.pl over real WebSocket frames.
- InteropSyncDriver: raw-WebSocket helper that drives NEG-OPEN /
NEG-MSG round trips against any NIP-77 relay and returns the
symmetric difference. No NostrClient indirection — same wire shape
strfry sync uses.
- GeodeVsGeodeNegentropySyncTest: boots two LocalRelayServer
instances, gives each a partially overlapping corpus, drives a
pull-sync, closes the loop with REQ + EVENT, asserts both sides
converge to the union. Bounded-rounds test on a 200-event corpus
guards against pathological framing regressions.
- GeodeVsStrfryNegentropySyncTest: opt-in via STRFRY_BIN env var (or
-Dstrfry.bin=...). When set, boots strfry as a subprocess on a
free loopback port, preloads the same corpus shape via EVENT, and
asserts Geode's client-side NegentropySession reconciles against
strfry's NEG server with the same haveIds/needIds split as the
Geode-vs-Geode case. When unset, prints [skip] and returns —
mirrors how LoadBenchmark gates on -DrunLoadBenchmark.
Per the agent-derived plan, this is the highest-signal
interoperability test we can run without porting upstream's
fuzz harness; it covers the e2e NEG-OPEN/NEG-MSG/NEG-CLOSE
lifecycle through NegSessionRegistry over real OkHttp frames,
which catches issues unit tests can't see (frame fragmentation,
WebSocket close semantics, kmp-negentropy ↔ strfry C++ wire
shape).