Skip to content

L1: foreground relay subscription (~25x speedup while Clave is open) - #11

Merged
DocNR merged 15 commits into
mainfrom
feat/foreground-relay-subscription
Apr 27, 2026
Merged

L1: foreground relay subscription (~25x speedup while Clave is open)#11
DocNR merged 15 commits into
mainfrom
feat/foreground-relay-subscription

Conversation

@DocNR

Copy link
Copy Markdown
Owner

Summary

Adds Layer 1 of the foreground RPC acceleration design — an @MainActor-isolated ForegroundRelaySubscription that holds long-lived WebSocket subscriptions to the user's relays whenever Clave is foregrounded, processing kind:24133 NIP-46 RPCs inline via LightSigner.handleRequest() instead of waiting on APNs+NSE.

Also closes audit item D.1.1 by consolidating the duplicate inline processedEventIDs dedupe logic from ClaveApp.swift and NotificationService.swift into a single SharedStorage.markEventProcessed() helper with insertion-ordered ring buffer + 60s age bound.

Why

Empirical: bunker mode (APNs+NSE) caps at ~1.6 dec/sec; foreground subscription hits 40.46 dec/sec at conc=5 (verified on real device against nak serve). For the dev's 7000-DM scenario, that's ~5.8 minutes vs 2.4 hours = ~25× speedup. Justifies the structural change and produces a markedly snappier in-app experience for any NIP-46 client (Wisp, Nostur, Coracle, etc.) — no client-side changes required.

What's in this PR

Production code

  • Shared/ForegroundRelaySubscription.swift (new, ~370 lines): @MainActor @Observable class. Per-relay URLSessionWebSocketTask actors. withTaskGroup-based dispatcher matching existing AppState/LightSigner concurrency patterns. Heartbeat (30s ping / 10s pong timeout), exponential reconnect backoff (1/2/4/8/16s). AsyncSemaphore for per-event concurrency cap (default 5, the empirical sweet spot from FINDINGS). 30s per-event budget enforced via withTaskGroup race against a sleep watchdog. Receive loop dispatches events to LightSigner.handleRequest with responseRelayUrl threaded back to the originating relay.
  • Shared/SharedStorage.swift: new markEventProcessed(eventId:, createdAt:) helper + new setClientRelayUrls() / getDebugTestRelay() / setDebugTestRelay() helpers. The dedupe helper is the single choke point both NSE and L1 inherit; the relayUrls setter populates ConnectedClient.relayUrls at pair time (it was a dead field added in PR feat: proxy-per-client-relay V2 (unblocks external past build 18) #9 V2 but never written by any code path until this PR).
  • Shared/LightSigner.swift: dedupe check at the top of handleRequest via markEventProcessed. Returns skipped-duplicate status when the event was already processed by another path.
  • Clave/ClaveApp.swift + ClaveNSE/NotificationService.swift: removed redundant inline dedupe blocks (now handled in LightSigner). Net deletion of ~95 lines of duplicated logic.
  • Clave/AppState.swift: startForegroundSubscription / stopForegroundSubscription@MainActor bridge methods. pairClientWithProxy now also calls setClientRelayUrls and triggers refreshRelaySet(). unpairClientWithProxy triggers refreshRelaySet().
  • Clave/Views/MainTabView.swift: scenePhase observer with 2s .inactive grace (cancels pending stop on app-switcher peek / control-center swipe). Confirmed background stops immediately.
  • Clave/Info.plist: NSAllowsLocalNetworking ATS exception so the new WebSocket sub can connect to plain ws:// hosts on the local network for dev verification (production traffic still uses wss://relay.powr.build).

Tests

  • ClaveTests/SharedStorageDedupTests.swift (new, 5 tests): first-seen, repeat, ring buffer cap, age eviction, concurrent within-process serialization.
  • ClaveTests/ForegroundRelaySubscriptionTests.swift (new, 5 tests): initial state, no-signer-key error path, resetCounters, stop-while-idle no-op, double-start idempotent.
  • All existing tests still green.

DEBUG-only test scaffolding (kept in #if DEBUG)

  • Settings → Developer → DEBUG: L1 Test Relay section. Lets a developer add an extra relay to L1's subscription set without going through nostrconnect pairing. Used during the L1 verification matrix to point L1 at nak serve on Mac LAN. Status indicator + live counters (Received / Processed / Failed). Apply / Clear buttons (each in its own row to avoid SwiftUI's HStack-tap-target footgun).

Production builds (Release) compile none of this DEBUG section.

Empirical evidence

~/hq/clave/research/nip17-bulk-decrypt/FINDINGS.md — full numbers, methodology, comparison tables. Headline:

PathThroughput @ conc=5p50 latencyp99 latencySuccess
Clave bunker (today)1.61/s2409ms7805ms98.79%
L1 production (this PR)40.46/s93ms488ms100%

Test plan

  • Unit tests: SharedStorageDedupTests (5), ForegroundRelaySubscriptionTests (5)
  • Build clean: xcodebuild -scheme Clave -configuration Debug ⇒ BUILD SUCCEEDED
  • All existing tests still green
  • Manual matrix on device: 1000-RPC harness run against nak serve, throughput 40.46/s, success 100%
  • Backgrounding lifecycle: 2s grace verified; confirmed-background stops immediately
  • Audit D.1.1 closed (insertion-ordered ring buffer + age bound replaces Set + .suffix(50))
  • Internal TestFlight matrix (post-merge)
  • Dual-path coexistence: foreground L1 + APNs both running, dedupe prevents double-processing — needs internal TestFlight (debug build uses sandbox APNs)
  • External TestFlight (after internal pass)

Spec / plan / prototype

  • Design spec: ~/hq/clave/specs/2026-04-26-foreground-bulk-decrypt-design.md
  • Implementation plan: ~/hq/clave/plans/2026-04-26-foreground-relay-subscription.md
  • Prototype branch (research artifact, will be deleted after this lands): feat/debug-foreground-subscription (tag research/fg-sub-prototype-2026-04-26)

Layer 2 (bulk decrypt session UX, conversation-key cache, auth_url heuristic) is a separate follow-up sprint that builds on top of L1 without modifying it.

🤖 Generated with Claude Code

DocNRand others added 15 commits April 26, 2026 14:59
@mainactor@observable final class with public start/stop/resetCounters
API surface and Observable counter properties. Dispatcher logic empty;
filled in subsequent tasks. First @mainactor introduction in Clave —
documented as deliberate in source comment.
Spec: ~/hq/clave/specs/2026-04-26-foreground-bulk-decrypt-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Shared/SharedStorage.swift markEventProcessed(eventId:, createdAt:)
helper with insertion-ordered ring buffer (200 entries) and age-bounded
eviction (60s by event.created_at). Replaces the prior Set + .suffix(50)
pattern duplicated inline in two call sites.
Audit follow-up D.1.1 is closed by this commit. The two inline call
sites (ClaveApp.swift, NotificationService.swift) are migrated in
later commits.
Cross-process semantics are deliberately lossy — NSE and main app race
on UserDefaults, occasional duplicate inserts produce double-publishes
which NIP-46 clients dedupe via response id. Within one process,
NSLock ensures atomic check-and-insert (test coverage).
5 unit tests added (ClaveTests/SharedStorageDedupTests.swift).
Spec: ~/hq/clave/specs/2026-04-26-foreground-bulk-decrypt-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Calls SharedStorage.markEventProcessed early in handleRequest, before
any decryption work. Returns 'skipped-duplicate' status when the event
was already processed by another path (NSE vs Layer 1 foreground sub).
This is the single dedupe choke point both NSE and Layer 1 inherit. The
duplicate inline checks in ClaveApp.swift / NotificationService.swift
become redundant and are removed in the next commit.
Handles both Double and Int decoding of `created_at` (Nostr relays send
the field as a number; JSONSerialization may surface it as either type).
All existing tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LightSigner.handleRequest now performs the dedupe check at the choke
point (Task 3). The duplicate inline blocks in ClaveApp.swift (foreground
push handler) and NotificationService.swift (NSE) are no longer needed.
Both call sites now treat status="skipped-duplicate" as a no-op continue.
Decrypt-failure handling is unchanged.
Net: fewer storage round-trips per duplicate; same dedupe semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-relay actor (RelayConnection) holding URLSessionWebSocketTask;
dispatcher Task with withTaskGroup spawning one runRelayLoop per relay
in the relaySet() (union of ConnectedClient.relayUrls + primary).
Each runRelayLoop: connect → REQ {kinds:[24133], #p:[user], since:now-60}
→ concurrent receive + heartbeat → on error, exponential backoff (1/2/4/8/16s).
Receive loop currently increments eventsReceived counter without
dispatching to LightSigner — per-event dispatch lands in Task 6.
The 60s `since` lookback catches events published just before
foregrounding (Layer 2's auth_url-triggered burst scenario).
Heartbeat: 30s ping; sendPing callback double-resume guarded with NSLock
to handle URLSessionWebSocketTask's cancel-after-pong race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each EVENT message acquires an AsyncSemaphore permit (cap 5, the empirical
sweet spot from FINDINGS.md) before spawning a child Task that calls
LightSigner.handleRequest with a 30s budget enforced via withTaskGroup
race against a sleep-then-cancel watchdog.
Counters (eventsProcessed, eventsFailed, recentLatenciesMs) update on
the MainActor via await MainActor.run blocks. The latency ring buffer
caps at 1024 samples for L2 progress UI to read.
processEvent is nonisolated since it does its own MainActor hops; this
keeps the per-event work off the main thread while updates land cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ption
Root SwiftUI view (MainTabView) observes scenePhase and dispatches
start()/stop() into ForegroundRelaySubscription.shared via
Task { @mainactor in ... }.
2s grace on .inactive (app-switcher peek, control-center swipe) prevents
spurious stop+restart cycles. Confirmed background stops immediately;
returning to active cancels any pending grace.
AppState gains startForegroundSubscription / stopForegroundSubscription
@mainactor methods so non-isolated callers hop in correctly.
project.pbxproj: register ForegroundRelaySubscription.swift in the Clave
target only (NOT NSE — keeps the NSE binary lean within the ~24MB
NSE memory limit; NSE doesn't reference the class).
Fixed: stored-property Self.defaultConcurrency reference inside the
class — replaced with the explicit type name to avoid the covariant Self
diagnostic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ForegroundRelaySubscription.refreshRelaySet() called from AppState
after successful pair or unpair flows. Implementation is stop+restart
for v1 (simplest correct); proper add/remove diffing is a follow-up.
Restart bounce is ~100ms — acceptable churn given pair/unpair are
infrequent operations.
Found-and-fixed: ConnectedClient.relayUrls was declared in SharedModels
but never populated by any code path. Added SharedStorage.setClientRelayUrls
which is now called from AppState.pairClientWithProxy. Without this,
Layer 1 would have only ever subscribed to the primary relay, missing
nostrconnect-paired clients' URI relays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tests covering: initial-or-post-stop state is idle, no-signer-key
sets error, resetCounters zeroes all counters, stop-while-idle is
no-op, double-start is idempotent. Real WebSocket I/O is tested in
the manual matrix (Task 10) — unit tests cover state-machine
boundaries only.
Also tightens stop() to set state=.idle synchronously rather than
.stopping-then-await-dispatcher-exit. The dispatcher cancellation
remains async (it has to be — tasks honor cancellation at suspension
points), but the public state machine is now observable-consistent:
callers see .idle immediately after stop() returns. This was needed
to make tests deterministic when run as a suite (the prior version
left state=.stopping briefly between tests, racing setUp's stop()).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For Layer 1 verification matrix (plans/2026-04-26-foreground-relay-subscription.md
Task 10): lets a developer add an extra relay to L1's subscription set
via the dev menu without going through a fresh nostrconnect pair.
Used to point L1 at `nak serve` on Mac LAN during testing.
All additions wrapped in #if DEBUG. Release builds (TestFlight, App
Store) compile none of this.
- SharedStorage: getDebugTestRelay / setDebugTestRelay accessors
- ForegroundRelaySubscription.relaySet(): includes the override URL
- SettingsView: new "DEBUG: L1 Test Relay" section with status, live
counters, relay URL field, Apply+Refresh / Clear buttons
Will be deleted before external promotion (alongside the prototype's
DebugForegroundSubscription cleanup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The @State default value used a function call (getDebugTestRelay()) which
SwiftUI evaluates each time the View struct is initialized — that caused
the field to clear on every view re-creation (e.g. tab switch, observable
property change), wiping in-progress edits.
Move the hydration into .onAppear with an empty-only check so:
- First open: loads any persisted value
- Edits persist across re-renders (since onAppear won't overwrite a
non-empty draft)
- After Apply + leave-and-return: re-loads the saved value
DEBUG-only; no production effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds NSAllowsLocalNetworking to ATS so the iOS app can connect to plain
ws:// hosts on the local network (e.g. nak serve on a developer's Mac
LAN during the L1 verification matrix).
Production traffic still uses wss:// (relay.powr.build); ATS continues
to enforce TLS for WAN connections. This exception only relaxes
local-network connections.
User confirmed plain ws:// worked with the prototype branch under the
same Info.plist, so this may have been redundant — but it's a defensive
inclusion that doesn't hurt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SwiftUI Form gotcha: two Buttons inside an HStack inside a Section row
become one tap target — tapping anywhere in the row fires BOTH button
actions. Observed in L1 verification: tapping Apply also fired Clear,
so the relay got set then immediately unset, and start() ran with the
cleared value (subscribing only to relay.powr.build, missing nak serve).
Fix: each button gets its own Form row + .buttonStyle(.borderless) so
SwiftUI treats them as independent tap targets.
DEBUG-only; no production effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the dev-menu test relay override and its supporting code. The
scaffolding was added for the L1 verification matrix to point
ForegroundRelaySubscription at nak serve on Mac LAN; production code
doesn't need it, and Info.plist's NSAllowsLocalNetworking ATS
relaxation is not #if DEBUG-gateable so it would have shipped to
Release.
- Clave/Info.plist: drop NSAppTransportSecurity dict
- Shared/SharedStorage.swift: drop debugTestRelayKey + accessors
- Shared/ForegroundRelaySubscription.swift: drop #if DEBUG branch in relaySet()
- Clave/Views/Settings/SettingsView.swift: drop l1TestSection + state
Future perf debugging (L2 sprint, Amber comparison) can resurrect via
the prototype tag research/fg-sub-prototype-2026-04-26 or a separate
test target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DocNR
DocNR merged commit ea2eca2 into mainApr 27, 2026
@DocNR
DocNR deleted the feat/foreground-relay-subscription branch April 27, 2026 02:52
@DocNRDocNR mentioned this pull request Apr 27, 2026
4 tasks
DocNR added a commit that referenced this pull request Apr 27, 2026
First build carrying the L1 foreground relay subscription (PR #11,
merge ea2eca2). Skips build 23 (burned by the expired build-22
diagnostic build).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DocNR added a commit that referenced this pull request Apr 28, 2026
* fix(L1): pending-approval UI refresh + banner notifications
Pre-L1, every sign request reached Clave via APNs → NSE, and NSE was the
sole producer of "Approve Signing Request" banners. Post-L1 (PR #11), when
Clave is foregrounded or in the 2s `.inactive` grace, L1 catches kind:24133
events directly via WebSocket and `SharedStorage.markEventProcessed` marks
the event id. NSE then runs from the same APNs push, sees the dedupe,
returns `.noEvents`, and produces a silent passive notification — correct,
since L1 already handled it. But L1 itself never emitted any user-facing
signal: no banner, and the in-process `SharedStorage.queuePendingRequest`
write didn't broadcast a refresh signal, so the pending-approvals list
only repopulated on tab-switch (HomeView's `.onAppear`) or full restart.
Two wires fix both symptoms:
1. Refresh signal (`.pendingRequestsUpdated`)
- `SharedStorage.queuePendingRequest` / `removePendingRequest` /
`clearPendingRequests` post the notification in-process.
- `AppState` observes it and refreshes `pendingRequests` on main.
`@Observable` propagates to any subscribed view automatically.
- `MainTabView` also refreshes on scenePhase `.active` so cross-process
NSE writes (while we were backgrounded) get picked up — the in-process
notification doesn't cross the NSE↔main-app boundary.
2. Banner emission (`PendingApprovalBanner`)
- New `Shared/PendingApprovalBanner.swift` schedules a
`UNNotificationRequest` matching the format NSE already uses for
pending pushes (title "Approve Signing Request", `.active`
interruption). Identifier is `pending-approval-<requestId>` so
approve/deny can `clear()` the delivered banner.
- `LightSigner.RequestResult` gains optional `pendingRequestId` so
callers can schedule with the same id used in the queued
`PendingRequest`.
- L1 `ForegroundRelaySubscription.processEvent` schedules on
`status == "pending"`. Foreground APNs push handler in `ClaveApp`
also schedules (defensive — covers the rare case where NSE didn't
process and main-app marked the event first; in normal operation
NSE wins the race and this path returns "skipped-duplicate").
- `AppState.approvePendingRequest` / `denyPendingRequest` call
`PendingApprovalBanner.clear` so the banner doesn't linger after the
user has acted on it.
- `ClaveApp.willPresent` now distinguishes:
- Locally-scheduled banners (id prefix `pending-approval-`) →
display directly, no re-processing.
- APNs pushes with non-empty NSE-modified title → display (was being
suppressed by unconditional `completionHandler([])`, hiding NSE's
own pending banner when the app was foreground).
- APNs pushes with empty title (NSE silent-success case) → suppress.
NSE's own `deliverContent` `.pending` path is unchanged — it still
produces a banner via `contentHandler` for the pure-background case
(NSE only, no L1, no foreground push handler). The dedupe ensures only
one of {NSE banner, L1-scheduled banner, foreground-handler-scheduled
banner} actually fires per event.
Verification:
- xcodebuild -scheme Clave -destination 'generic/platform=iOS' build →
BUILD SUCCEEDED
- xcodebuild test on iPhone 17 Pro Max sim (iOS 26.4) → TEST SUCCEEDED
- Device test: needs build 25 archive + TestFlight install (next step).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): pending-card padding, snapshot protection, ClientDetailView UX
Bundled UI/UX improvements going out alongside the L1 pending-approval
refresh + banner fix in this PR so they all ride one TestFlight.
**1. Pending approvals card edge padding**
[Clave/Views/Home/PendingApprovalsView.swift] HomeView wraps it in a
Section with `.listRowInsets(EdgeInsets())`, so the card has to self-pad
to match its sibling rows (identityBar, statsRow). Added the missing
`.padding(.horizontal)` after `.background`. Pre-existing since v1.0
UX sprint, not an L1 regression — only surfaced now because pending
approvals weren't refreshing reliably enough for users to notice.
**2. App-switcher snapshot protection (audit A10.1)**
New `Clave/Views/Components/SnapshotProtected.swift` — ViewModifier that
overlays the receiver with an opaque privacy view whenever scenePhase is
not `.active`. Applied via `.snapshotProtected()` on the four sheets that
render sensitive material:
- ExportKeySheet (nsec)
- ConnectSheet (bunker URI containing the bunker secret)
- QRCodeView (QR code of bunker URI)
- ApprovalSheet (incoming approval request, including client identity)
iOS captures the app-switcher snapshot during the `.inactive` transition,
so a per-sheet overlay is sufficient and avoids blanking the rest of the
app on benign control-center swipes.
**3. AvatarView prefers name initials over pubkey prefix**
[Clave/Views/Components/AvatarView.swift] gains an optional `name: String?`
param. When set and non-blank, shows up to two letters derived from the
name (e.g. "Joe Bloggs" → "JB"; "Yakihonne" → "YA") in a proportional
font. Pubkey-prefix fallback unchanged (monospaced). Gradient remains
pubkey-derived so renames don't change the avatar color — only the text
inside the circle. Updated four call sites to pass the available name:
ClientDetailView header, HomeView clientRow + identityBar, ApprovalSheet.
**4. ClientDetailView header + toolbar overhaul**
- Tap the client name (or pencil affordance next to it) → existing
Rename alert. The bottom-of-screen Rename button is removed.
- Toolbar overflow menu (`ellipsis.circle`, top-right) now houses:
- Connection Info → opens new `ConnectionInfoSheet`
- Rename → same alert as the header tap
- Unpair Client (destructive) → existing confirm alert
- Bottom `actionsSection` removed entirely. The Unpair button used to
live underneath the recent-activity list, which buried it.
- New `ConnectionInfoSheet` shows: name, origin URL, npub, hex pubkey
(both copyable), trust level, first-connected/last-seen timestamps,
total request count, and the relay set the proxy watches for this
client (when available via `ConnectedClient.relayUrls`).
Verification:
- xcodebuild -scheme Clave -destination 'generic/platform=iOS' build →
BUILD SUCCEEDED
- xcodebuild test on iPhone 17 Pro Max sim (iOS 26.4) → TEST SUCCEEDED
- Device test: build 25 archive (next step).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(notifications): sweep blank NC entries on app foreground
User reports: blank Notification Center entries accumulating again, much
worse with L1 than pre-L1.
Cause: every NSE wake for an event L1 already processed returns
"skipped-duplicate" → SigningResult(.noEvents) → NSE delivers content
with empty title + .passive interruption, then calls
removeDeliveredNotifications. The remove is racy — NSE process often
exits before iOS commits the notification, so the remove no-ops and
the blank entry sticks. L1 amplifies this because most APNs wakes are
now "L1 already handled it" duplicates rather than real work for NSE.
iOS doesn't expose a "deliver but don't add to NC" hint — `.passive`
just suppresses banner+sound, the NC entry is mandatory once the push
arrives. So we have to clean up after the fact.
Fix: on every scenePhase → .active in MainTabView, query
getDeliveredNotifications and remove any with empty title. Locally-
scheduled pending-approval banners ("Approve Signing Request"),
sign-failure banners ("Signing Failed"), and other real notifications
keep their title and are preserved. The main app process lives long
enough for the async UNUserNotificationCenter API to actually complete,
which the short-lived NSE process does not.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@DocNR