diff --git a/desktopApp/benchmarks/README.md b/desktopApp/benchmarks/README.md new file mode 100644 index 0000000000..39adb6fa28 --- /dev/null +++ b/desktopApp/benchmarks/README.md @@ -0,0 +1,49 @@ +# Launch Benchmark + +Single-JVM warm benchmark for the cold-boot critical path. See +[`desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md`](../plans/2026-06-17-feat-app-launch-optimization-plan.md) +for the design. + +## Run + +```bash +AMETHYST_BENCH=true ./gradlew :desktopApp:test \ + --tests "*LaunchBenchmark.run" --rerun-tasks +``` + +Without the env var the test class skips itself silently so a normal +`./gradlew :desktopApp:test` stays fast. + +## Output + +A per-git-sha report lands at +`desktopApp/build/benchmarks/launch-.txt`. The benchmark prints the +report to stdout and atomically writes the file via +`Files.move(... ATOMIC_MOVE)` so a killed run does not pollute the trend +data with partial content. + +## Reading the numbers + +Each row reports `n`, min, q1, median, q3, max in milliseconds. The +benchmark drives the slim "non-Compose" cold-boot scenario: +`AccountManager.loadSavedAccount` → relay subscription via +`InProcessWebsocketBuilder` (fixture relay) → `DesktopLocalCache.consume`. + +- `t_account_logged_in` — `AccountManager.accountState` reaches + `LoggedIn(isReadOnly=true)`. +- `t_first_event` — first `kind:1` flows through `DesktopLocalCache.consume`. +- `t_n_events` — `n`th (default `n=10`) `kind:1` flows through the cache. + +Numbers are dominated by the harness floor (`InProcessWebSocket` channel +hops, fixture-server REQ matching, coroutine dispatcher schedule). They +are most useful as a **regression guard** for code already in the +exercised path; they do **not** approximate a real Skia/Swing first paint. +Layered Compose-driven and JVM-fork variants are tracked as deferred +follow-ups in the plan. + +## Snapshots committed here + +- `baseline-main.txt` — pre-Phase-5.2 baseline. +- `with-phase5-fixes.txt` — post-Phase-5.2 snapshot. + +Diff manually with `diff -u baseline-main.txt with-phase5-fixes.txt`. diff --git a/desktopApp/benchmarks/baseline-main.txt b/desktopApp/benchmarks/baseline-main.txt new file mode 100644 index 0000000000..e3b5ccb9e9 --- /dev/null +++ b/desktopApp/benchmarks/baseline-main.txt @@ -0,0 +1,19 @@ +# LaunchBenchmark report — PRE-PHASE-5.2 baseline +# Captured BEFORE the Main.kt bootstrap-gate removal. The slim benchmark +# does not drive the App() bootstrap subscription path, so this row is +# primarily a harness-floor sanity reference for the cold-boot +# relay→cache pipeline; the gate fix itself is validated by +# SubscribeBeforeConnectTest rather than by a measurable delta here. +# date 2026-06-18T07:46:13.631678Z +# jvm 21.0.9 Homebrew +# os Mac OS X 26.5 aarch64 +# cpus 10 +# max-heap-mb 512 +# git-sha 48726ee4df (after Phase 1 pyramid + Phase 5.1 icon fix) +# iterations 5 (after 2 warmup, discarded) +# fork-mode single-JVM (cold-fork driver deferred) + +t_account_logged_in n=5 min= 0.27ms q1= 0.27ms median= 0.31ms q3= 0.34ms max= 0.47ms +t_first_event n=5 min= 0.79ms q1= 0.87ms median= 0.95ms q3= 1.11ms max= 1.15ms +t_n_events n=5 min= 1.27ms q1= 1.35ms median= 1.36ms q3= 1.48ms max= 2.38ms +# events-consumed per iteration: [11, 12, 12, 12, 13] diff --git a/desktopApp/benchmarks/with-phase5-fixes.txt b/desktopApp/benchmarks/with-phase5-fixes.txt new file mode 100644 index 0000000000..20faf4207c --- /dev/null +++ b/desktopApp/benchmarks/with-phase5-fixes.txt @@ -0,0 +1,21 @@ +# LaunchBenchmark report — POST-PHASE-5.2 (gate removal) snapshot +# Captured AFTER the Main.kt bootstrap-gate removal. NOTE: the slim +# benchmark does not actually exercise the gated code path (the gate is +# inside the App() composable, which this harness does not drive). The +# row remains a harness-floor sanity check; the gate-removal payoff +# manifests during real App() boot when no relay has connected yet and +# the previous 30s `withTimeoutOrNull` would otherwise idle the +# subscription. +# date 2026-06-18T07:57:51.519608Z +# jvm 21.0.9 Homebrew +# os Mac OS X 26.5 aarch64 +# cpus 10 +# max-heap-mb 512 +# git-sha 48726ee4df23bf97c6e302766af42e048c3060f0 +# iterations 5 (after 2 warmup, discarded) +# fork-mode single-JVM (cold-fork driver deferred) + +t_account_logged_in n=5 min= 0.21ms q1= 0.48ms median= 0.72ms q3= 0.87ms max= 1.15ms +t_first_event n=5 min= 1.17ms q1= 1.34ms median= 1.46ms q3= 2.06ms max= 2.10ms +t_n_events n=5 min= 1.51ms q1= 1.76ms median= 2.63ms q3= 2.75ms max= 3.55ms +# events-consumed per iteration: [23, 50, 15, 28, 23] diff --git a/desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md b/desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md index 135b03c615..3f67e6726b 100644 --- a/desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md +++ b/desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md @@ -11,14 +11,38 @@ deepened: 2026-06-17 ## Progress Log -| Date | Phase | Outcome | Commit | -|------------|-------|------------------------------------------------------------------------------------------|-------------| -| 2026-06-17 | 1.1 | `AccountManagerLoadStateTransitionsTest` — 2 tests pass | `ff55898ab` | -| 2026-06-17 | 1.2 | `LocalRelayStoreHydrationTest` — 5 tests pass | `ff55898ab` | -| 2026-06-17 | 1.3 | `LocalRelayStore` gains `homeDir` ctor param (default unchanged) | `ff55898ab` | -| 2026-06-17 | 5.1 | `IconResources` collapses 4 sites + 2 `ImageIO.read` calls into one lazy each; 5 tests | `b338d7db4` | +| Date | Phase | Outcome | Commit | +|------------|-----------|------------------------------------------------------------------------------------------|-------------| +| 2026-06-17 | 1.1 | `AccountManagerLoadStateTransitionsTest` — 2 tests pass | `ff55898ab` | +| 2026-06-17 | 1.2 | `LocalRelayStoreHydrationTest` — 5 tests pass | `ff55898ab` | +| 2026-06-17 | 1.3 | `LocalRelayStore` gains `homeDir` ctor param (default unchanged) | `ff55898ab` | +| 2026-06-17 | 5.1 | `IconResources` collapses 4 sites + 2 `ImageIO.read` calls into one lazy each; 5 tests | `b338d7db4` | +| 2026-06-18 | 2.1 / 2.2 | `InProcessWebsocketBuilder` + `LaunchFixtureRelay` (wraps quartz `InProcessWebSocket` + `NostrServer` with `EmptyPolicy`); roundtrip test green | next commit | +| 2026-06-18 | 2.3 | `LaunchFixture` synthesizes 50 kind:1 + author kind:0 + kind:3 + kind:10002 from a fixed RNG seed (no JSONL artifact / `amy` dependency) | next commit | +| 2026-06-18 | 3.1 | `LaunchMarkers` (single-threaded `mutableMapOf` + `TimeSource.Monotonic`) + `LocalNoteCardInstrumentation` CompositionLocal + `NoteCard.Modifier.testTag(NOTE_CARD_TEST_TAG).onPlaced { … }` instrumentation — production overhead = 1 composition-local read + 1 null check | next commit | +| 2026-06-18 | 3.2 / 4 | `LaunchBenchmark` warm harness (2 warmup + 5 measured, median/IQR/min, atomic file write, JVM/OS/arch in header, opt-in via `AMETHYST_BENCH=true`). Baseline captured at `desktopApp/benchmarks/baseline-main.txt` | next commit | +| 2026-06-18 | 5.2 | `SubscribeBeforeConnectTest` proves `NostrClient`/`RelayPool` queue REQs pre-connect; `Main.kt:1242` bootstrap gate `connectedRelays.first { isNotEmpty() }` + 30s `withTimeoutOrNull` removed — subscription fires eagerly and the pool flushes on connect | next commit | +| 2026-06-18 | 6 | Post-fix snapshot at `desktopApp/benchmarks/with-phase5-fixes.txt` | next commit | -**Next on the critical path:** Phase 1.4 (App() Compose smoke test — needs DeckState / WorkspaceManager / TorManager mocks) → Phase 2 (in-process relay seam in `:quartz` testFixtures) → Phase 3 (benchmark harness) → Phase 4 (baseline) → Phase 5.2 (feed bootstrap gate fix). Phase 5.1 has already landed but its end-to-end delta will be measured once Phase 3 is in place. +**Still pending after this session:** + +- **Phase 1.4** — `App()` Compose UI smoke test. Hard-blocked on mocking the + six App() dependencies that have substantial real implementations + (DeckState, WorkspaceManager, DesktopTorManager, TorType / port flows, + initialTorSettings). The `BenchmarkRelayConnectionManager` subclass added + in 3.2 is the seam the future App() harness will use. +- **Phase 2.4** — wire fixture relay into `AppStateMachineTest` (depends on + Phase 1.4). +- **Cold-fork shell driver** for the benchmark (per-sample JVM fork). The + current single-JVM harness measures the same code path at the + classloader-+-JIT-warm regime; the cold-fork variant adds JVM-startup + costs into the picture. +- **Compose-driving variant** of the benchmark (wires `LaunchMarkers` to + `LocalNoteCardInstrumentation` for real-paint timing). +- The Phase 5.2 fix's measurable delta — the slim non-Compose benchmark + does not drive the App() gate, so the gate fix is validated by the + `SubscribeBeforeConnectTest` invariant rather than by a benchmark delta + in this report. ## Enhancement Summary @@ -512,7 +536,7 @@ Five scenarios unit tests with mocks won't catch: - [ ] **Phase 3.2**: Cold harness (shell-script, fork per sample, N=10) and warm harness (single JVM, N=20, discard 5 warmup) both run reproducibly. Pinned JVM flags. Output headers include git SHA, JVM/OS/arch. Control benchmark (`setContent { Box {} }`) reports harness floor. - [ ] **Phase 4**: `desktopApp/benchmarks/baseline-main-cold.txt` and `-warm.txt` committed; plan updated with numbers. Re-run delta ≤ 15% median-to-median. - [x] **Phase 5.1**: icon decoded exactly once per process (unit test); microbench delta reported; end-to-end delta reported. _Code + unit test landed 2026-06-17 (commit `b338d7db4`). Delta numbers pending Phase 3 benchmark harness._ -- [ ] **Phase 5.2**: bootstrap subscription fires before any relay reaches CONNECTED state OR Pool queues pre-connect (whichever investigation shows). 4 new tests: `slowRelayDoesNotStallFeed`, `noRelaysAvailableShowsErrorState`, `relayListArrivesLateStartsSubscriptionThen`, `relaysAddedMidLoadDoNotDoubleSubscribe`. Home + DM share single helper. +- [x] **Phase 5.2** (partial): investigation chose candidate (a) — `NostrClient`/`RelayPool` queue REQs pre-connect (verified by `SubscribeBeforeConnectTest`). The bootstrap gate at `Main.kt:1242` is removed and the subscription fires eagerly. The four planned regression tests (`slowRelayDoesNotStallFeed`, `noRelaysAvailableShowsErrorState`, `relayListArrivesLateStartsSubscriptionThen`, `relaysAddedMidLoadDoNotDoubleSubscribe`) are still pending — they require driving the `App()` composable, which is blocked on Phase 1.4 harness work. The DM gate at `Main.kt:1290` is left untouched in this session (separate code path through `subscriptionsCoordinator`). ### Non-Functional Requirements diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index f9534095d6..0a00e371ae 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -137,8 +137,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.time.Duration.Companion.seconds private val isMacOS = com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS @@ -1237,53 +1235,54 @@ fun MainContent( .DesktopDraftStore(appScope) } - // Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006) - // Uses DisposableEffect to clean up subscription on account change + // Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006). + // Subscribes immediately — `NostrClient` / `RelayPool` queue REQs that arrive before a + // relay connection is up and flush them on connect (verified by + // SubscribeBeforeConnectTest), so the previous + // `connectedRelays.first { isNotEmpty() }` + 30s timeout gate has been + // removed. Per the Phase 5.2 launch-optimization plan, this shaves the + // cold-boot relay-bootstrap latency from "first connect roundtrip + sub + // dispatch" to "sub dispatch only" once a relay is available, and it + // also recovers gracefully when no relay ever connects (the + // subscription stays queued for when one does, instead of silently + // giving up after 30s). DisposableEffect(accountRelays) { val bootstrapSubId = "bootstrap-relay-config" - scope.launch { - val connected = - withTimeoutOrNull(30.seconds) { - relayManager.connectedRelays.first { it.isNotEmpty() } - } - if (connected != null) { - val filter = - Filter( - kinds = - listOf( - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - BlockedRelayListEvent.KIND, - ), - authors = listOf(account.pubKeyHex), - limit = 4, - ) - relayManager.subscribe( - subId = bootstrapSubId, - filters = listOf(filter), - listener = - object : SubscriptionListener { - override fun onEvent( - event: com.vitorpamplona.quartz.nip01Core.core.Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // NIP-65 (kind 10002) must go through justConsumeMyOwnEvent - // because localCache.consume() doesn't handle addressable events - if (event is AdvertisedRelayListEvent) { - scope.launch(Dispatchers.IO) { - localCache.justConsumeMyOwnEvent(event) - } - } - // Route to accountRelays for persistence + state updates - accountRelays.consumeIfRelevant(event) + val filter = + Filter( + kinds = + listOf( + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + BlockedRelayListEvent.KIND, + ), + authors = listOf(account.pubKeyHex), + limit = 4, + ) + relayManager.subscribe( + subId = bootstrapSubId, + filters = listOf(filter), + listener = + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // NIP-65 (kind 10002) must go through justConsumeMyOwnEvent + // because localCache.consume() doesn't handle addressable events + if (event is AdvertisedRelayListEvent) { + scope.launch(Dispatchers.IO) { + localCache.justConsumeMyOwnEvent(event) } - }, - ) - } - } + } + // Route to accountRelays for persistence + state updates + accountRelays.consumeIfRelevant(event) + } + }, + ) onDispose { relayManager.unsubscribe(bootstrapSubId) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 6988a8c6de..269507999a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -50,6 +50,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -164,6 +166,25 @@ fun NoteCard( } val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface) val cardShape = MaterialTheme.shapes.medium + + // Launch-instrumentation hook. The composition-local read is one slot lookup + // and the resulting `instrumentation` reference is null in production, so the + // onPlaced callback is a single null check per placement. See + // desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 3.1. + val instrumentation = LocalNoteCardInstrumentation.current + val instrumentedModifier = + remember(modifier, instrumentation, note.id) { + var fired = false + modifier + .testTag(NOTE_CARD_TEST_TAG) + .onPlaced { + if (!fired && instrumentation != null) { + fired = true + instrumentation.onPlaced(note.id) + } + } + } + val cardBody: @Composable ColumnScope.() -> Unit = { Column(modifier = Modifier.padding(16.dp)) { // Reply context — embedded parent + "Replying to @X" label. @@ -380,7 +401,7 @@ fun NoteCard( if (onClick != null) { OutlinedCard( onClick = onClick, - modifier = modifier, + modifier = instrumentedModifier, colors = cardColors, border = cardBorder, shape = cardShape, @@ -388,7 +409,7 @@ fun NoteCard( ) } else { OutlinedCard( - modifier = modifier, + modifier = instrumentedModifier, colors = cardColors, border = cardBorder, shape = cardShape, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCardInstrumentation.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCardInstrumentation.kt new file mode 100644 index 0000000000..7fec38b155 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCardInstrumentation.kt @@ -0,0 +1,54 @@ +/* + * 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.amethyst.desktop.ui.note + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +/** + * Stable identifier used by UI tests + benchmark harness to locate + * [NoteCard] roots in the semantic tree. Production code MUST NOT key + * behavior off this tag — it exists purely for observation. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 3.1. + */ +const val NOTE_CARD_TEST_TAG: String = "amethyst.desktop.note_card.root" + +/** + * Optional callback fired from [NoteCard]'s `Modifier.onPlaced` site. + * + * Production code provides `null` (the default value of + * [LocalNoteCardInstrumentation]) so the callback site is a single + * composition-local read + null check — no allocation, no work. Benchmark + * tests provide a counter that records `t_first_event` / `t_n_events` + * markers without polling the semantic tree. + */ +fun interface NoteCardInstrumentation { + fun onPlaced(noteId: String) +} + +/** + * Composition local read by [NoteCard]. Default `null` — no test + * harness wired, no overhead. Tests override via `CompositionLocalProvider`. + */ +val LocalNoteCardInstrumentation: ProvidableCompositionLocal = + compositionLocalOf { null } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchBenchmark.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchBenchmark.kt new file mode 100644 index 0000000000..23cd7ae70f --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchBenchmark.kt @@ -0,0 +1,181 @@ +/* + * 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.amethyst.desktop.benchmark + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Phase 3.2: single-JVM warm benchmark for the cold-boot scenario. + * + * Runs [WARMUP] discarded iterations and [ITERATIONS] measured iterations + * of [LaunchScenario.coldBoot], collects the median + min + IQR of each + * marker, and atomically writes a report file under + * `desktopApp/build/benchmarks/`. The "cold" half of the harness (forking + * a fresh JVM per sample) is deferred to a shell-script driver; this + * single-JVM variant gives meaningful before/after numbers for fixes that + * stay in the launch-path code we already exercise (icon decode, feed + * bootstrap gate, etc.). + * + * Disabled by default — set the `AMETHYST_BENCH=true` environment variable + * to run it (so a normal `./gradlew :desktopApp:test` stays fast). + * Invoked directly via: + * + * AMETHYST_BENCH=true ./gradlew :desktopApp:test \ + * --tests "*LaunchBenchmark.run" --rerun-tasks + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 3.2. + */ +class LaunchBenchmark { + @Test + fun run() { + if (System.getenv("AMETHYST_BENCH") != "true" && System.getProperty("amethyst.bench") != "true") { + // Skip silently. Set AMETHYST_BENCH=true (or pass + // -Damethyst.bench=true to the test JVM) to run it. + println("LaunchBenchmark: skipped — set AMETHYST_BENCH=true to run") + return + } + + // Drop the warmup samples on the floor so the measured set isn't + // biased by classloader cost or JIT C1 compilation. + repeat(WARMUP) { + LaunchScenario.coldBoot() + } + + val samples = (1..ITERATIONS).map { LaunchScenario.coldBoot() } + + val report = buildReport(samples) + writeReport(report) + println(report) + + val nEventsSamples = samples.mapNotNull { it.markers[LaunchMarkers.T_N_EVENTS] } + assertTrue( + nEventsSamples.size >= ITERATIONS / 2, + "At least half the iterations must reach T_N_EVENTS; got ${nEventsSamples.size}/$ITERATIONS", + ) + } + + private fun buildReport(samples: List): String { + val header = + buildString { + appendLine("# LaunchBenchmark report") + appendLine("# date ${java.time.Instant.now()}") + appendLine("# jvm ${System.getProperty("java.version")} ${System.getProperty("java.vendor")}") + appendLine("# os ${System.getProperty("os.name")} ${System.getProperty("os.version")} ${System.getProperty("os.arch")}") + appendLine("# cpus ${Runtime.getRuntime().availableProcessors()}") + appendLine("# max-heap-mb ${Runtime.getRuntime().maxMemory() / 1024 / 1024}") + appendLine("# git-sha ${gitSha()}") + appendLine("# iterations $ITERATIONS (after $WARMUP warmup, discarded)") + appendLine("# fork-mode single-JVM (cold-fork driver deferred)") + appendLine() + } + + val markerNames = + listOf( + LaunchMarkers.T_ACCOUNT_LOGGED_IN, + LaunchMarkers.T_FIRST_EVENT, + LaunchMarkers.T_N_EVENTS, + ) + + val rows = + markerNames.map { name -> + val vals = samples.mapNotNull { it.markers[name]?.inWholeMicroseconds } + val md = vals.median() + val mn = vals.minOrNull() ?: 0 + val mx = vals.maxOrNull() ?: 0 + val q1 = vals.percentile(25.0) + val q3 = vals.percentile(75.0) + "%-30s n=%d min=%6.2fms q1=%6.2fms median=%6.2fms q3=%6.2fms max=%6.2fms".format( + name, + vals.size, + mn / 1000.0, + q1 / 1000.0, + md / 1000.0, + q3 / 1000.0, + mx / 1000.0, + ) + } + + val eventsCounts = samples.map { it.eventsConsumed } + val tail = + buildString { + appendLine() + appendLine("# events-consumed per iteration: $eventsCounts") + } + + return header + rows.joinToString("\n") + tail + } + + private fun writeReport(report: String) { + val sha = gitSha().take(10) + val dir = File("build/benchmarks").also { it.mkdirs() } + val target = File(dir, "launch-$sha.txt") + val tmp = File(dir, "launch-$sha.tmp") + tmp.writeText(report) + Files.move( + tmp.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + println("LaunchBenchmark: report at ${target.absolutePath}") + } + + private fun gitSha(): String = + runCatching { + val proc = + ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start() + proc.waitFor() + proc.inputStream + .bufferedReader() + .readText() + .trim() + }.getOrDefault("unknown") + + companion object { + private const val WARMUP = 2 + private const val ITERATIONS = 5 + } +} + +private fun List.median(): Long { + if (isEmpty()) return 0 + val sorted = sorted() + val mid = sorted.size / 2 + return if (sorted.size % 2 == 0) { + (sorted[mid - 1] + sorted[mid]) / 2 + } else { + sorted[mid] + } +} + +private fun List.percentile(p: Double): Long { + if (isEmpty()) return 0 + val sorted = sorted() + val idx = ((sorted.size - 1) * p / 100.0).toInt().coerceIn(0, sorted.lastIndex) + return sorted[idx] +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchMarkers.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchMarkers.kt new file mode 100644 index 0000000000..259b6b8bf0 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchMarkers.kt @@ -0,0 +1,98 @@ +/* + * 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.amethyst.desktop.benchmark + +import com.vitorpamplona.amethyst.desktop.ui.note.NoteCardInstrumentation +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * Single-threaded marker registry used by the launch benchmark and the + * Compose smoke tests. Records named timestamps relative to a single + * `start()` reference; later calls to `mark(name)` with an existing name + * are ignored so the first-occurrence semantics of `t_first_event` / + * `t_n_events` are stable. + * + * Not thread-safe — Gradle test parallelism for any class touching this + * registry must be set to `maxParallelForks = 1`. The Compose UI test + * rule already serializes, and the benchmark runner forks a fresh JVM + * per cold sample, so this is not a practical limit. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 3.1. + */ +object LaunchMarkers { + private val timestamps = mutableMapOf() + private var start: TimeMark? = null + private val noteCardCounter = AtomicInteger(0) + + const val T_FIRST_COMPOSITION_APPLY: String = "t_first_composition_apply" + const val T_ACCOUNT_LOGGED_IN: String = "t_account_logged_in" + const val T_FIRST_EVENT: String = "t_first_event" + const val T_N_EVENTS: String = "t_n_events" + + /** Default count for the headline `t_n_events` metric. */ + const val DEFAULT_N: Int = 10 + + /** Begin a measurement window. Clears any previously recorded markers. */ + fun start() { + timestamps.clear() + noteCardCounter.set(0) + start = TimeSource.Monotonic.markNow() + } + + /** + * Record [name] if not already recorded. Returns true when this call + * was the one to record it. + */ + fun mark(name: String): Boolean { + val ref = start ?: return false + if (name in timestamps) return false + timestamps[name] = ref.elapsedNow() + return true + } + + /** Snapshot of all recorded markers in insertion order. */ + fun snapshot(): Map = timestamps.toMap() + + /** + * [NoteCardInstrumentation] adapter that records `t_first_event` on + * the first placement and `t_n_events` on the [n]th distinct one. + * Counts distinct `noteId`s — a recomposition that re-emits the same + * card is not counted twice. + */ + fun noteCardInstrumentation(n: Int = DEFAULT_N): NoteCardInstrumentation { + val seen = + java.util.concurrent.ConcurrentHashMap + .newKeySet() + return NoteCardInstrumentation { noteId -> + if (seen.add(noteId)) { + val placed = noteCardCounter.incrementAndGet() + when { + placed == 1 -> mark(T_FIRST_EVENT) + placed >= n -> mark(T_N_EVENTS) + } + } + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt new file mode 100644 index 0000000000..53eeb778ac --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt @@ -0,0 +1,202 @@ +/* + * 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.amethyst.desktop.benchmark + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo +import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore +import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixture +import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixtureRelay +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +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.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import kotlin.io.path.createTempDirectory +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Runs one iteration of the cold-boot scenario: empty home directory, + * empty events.db, a single ViewOnly account preloaded into + * `accounts.json.enc`, and an in-process [LaunchFixtureRelay] standing in + * for the network. + * + * Records into [LaunchMarkers] the timestamps the benchmark cares about: + * - [LaunchMarkers.T_ACCOUNT_LOGGED_IN] — `AccountManager.accountState` + * reaches `LoggedIn(isReadOnly=true)`. + * - [LaunchMarkers.T_FIRST_EVENT] — first `kind:1` flows through + * `DesktopLocalCache.consume`. + * - [LaunchMarkers.T_N_EVENTS] — `n`th `kind:1` flows through the cache. + * + * Returns when [n] kind:1 events have been consumed, the EOSE for the + * subscription arrived, or the 30s [overallTimeout] elapses (whichever is + * first). + * + * This is the slim "non-Compose" benchmark path: it exercises the cold-boot + * critical-path code (AccountManager + LocalCache + RelayConnectionManager + * + LocalRelayStore) without trying to drive the full `App()` Compose + * composable, which would require mocking DeckState / WorkspaceManager / + * TorManager. A Compose-driven variant can layer onto this once the App() + * smoke-test harness lands in Phase 1.4. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 3.2. + */ +object LaunchScenario { + data class Result( + val markers: Map, + val eventsConsumed: Int, + ) + + fun coldBoot( + n: Int = LaunchMarkers.DEFAULT_N, + fixture: LaunchFixture = LaunchFixture.build(), + overallTimeout: Duration = 30.seconds, + ): Result = + runBlocking { + val tempHome = createTempDirectory("launch-scenario").toFile() + val storage = mockk(relaxed = true) + coEvery { storage.getPrivateKey(any()) } returns null + File(tempHome, ".amethyst").mkdirs() + + val account = AccountManager(storage, tempHome) + val ownerNpub = fixture.ownerKeyPair.pubKey.toNpub() + account.accountStorage.saveAccount( + AccountInfo(npub = ownerNpub, signerType = SignerType.ViewOnly), + ) + account.accountStorage.setCurrentAccount(ownerNpub) + + val cache = DesktopLocalCache() + val storeScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val localRelayStore = LocalRelayStore(scope = storeScope, homeDir = tempHome) + localRelayStore.openForAccount(fixture.ownerKeyPair.pubKey.toHexKey()) + + val relay = LaunchFixtureRelay.open(fixture.events) + val relayManager = BenchmarkRelayConnectionManager(relay.builder) + + val eventCounter = AtomicInteger(0) + val eoseSignal = CompletableDeferred() + val nReached = CompletableDeferred() + + try { + LaunchMarkers.start() + + // Account decrypt + ViewOnly load. The accountState collector + // below picks up the LoggedIn emission and records the marker. + val accountWatcher = + storeScope.launch { + account.accountState.first { it is AccountState.LoggedIn } + LaunchMarkers.mark(LaunchMarkers.T_ACCOUNT_LOGGED_IN) + } + + account.loadSavedAccount() + relayManager.connect() + + relayManager.client.subscribe( + subId = "launch-bench-home", + filters = + mapOf( + LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to + listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), + ), + listener = + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.kind != TextNoteEvent.KIND) return + val consumed = + cache.consume(event, relay, wasVerified = true) + if (!consumed) return + val placed = eventCounter.incrementAndGet() + if (placed == 1) LaunchMarkers.mark(LaunchMarkers.T_FIRST_EVENT) + if (placed >= n) { + LaunchMarkers.mark(LaunchMarkers.T_N_EVENTS) + nReached.complete(Unit) + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (!eoseSignal.isCompleted) eoseSignal.complete(Unit) + } + }, + ) + + withTimeout(overallTimeout) { + // Block until either N events landed or the fixture is + // exhausted (EOSE). Whichever happens first is the + // scenario terminator. + if (eventCounter.get() < n) { + kotlinx.coroutines.selects.select { + nReached.onAwait { } + eoseSignal.onAwait { } + } + } + } + + accountWatcher.cancel() + Result(LaunchMarkers.snapshot(), eventCounter.get()) + } finally { + relayManager.disconnect() + relay.close() + localRelayStore.close() + storeScope.cancel() + tempHome.deleteRecursively() + } + } +} + +/** + * Open the `websocketBuilder` ctor parameter so the benchmark can substitute + * the in-process builder. Production callers go through + * [com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager] + * which wires OkHttp. + */ +private class BenchmarkRelayConnectionManager( + builder: WebsocketBuilder, +) : RelayConnectionManager(builder) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/InProcessWebsocketBuilder.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/InProcessWebsocketBuilder.kt new file mode 100644 index 0000000000..d79877c07e --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/InProcessWebsocketBuilder.kt @@ -0,0 +1,50 @@ +/* + * 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.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder + +/** + * Phase 2.1 of the launch-optimization plan: a [WebsocketBuilder] that routes + * every connection to an in-process [NostrServer] via [InProcessWebSocket], + * skipping the network entirely. + * + * Lives in `desktopApp/src/jvmTest` rather than `:quartz/src/testFixtures` so + * we avoid the KMP + `java-test-fixtures` interaction documented as Risk #1 + * in the plan. Promote to a shared fixtures module only when Android picks + * up the same harness. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 2.1. + */ +class InProcessWebsocketBuilder( + private val server: NostrServer, +) : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket = InProcessWebSocket(server, out) +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixture.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixture.kt new file mode 100644 index 0000000000..44f0356cf5 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixture.kt @@ -0,0 +1,219 @@ +/* + * 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.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * Phase 2.3 of the launch-optimization plan: a deterministic, synthetic + * "home feed" snapshot used to drive the in-process relay during benchmarks + * and Compose UI tests. + * + * The original plan called for a real-world capture (e.g. fiatjaf's latest + * 50 kind:1 + author metadata) committed as a JSONL artifact. Because we + * cannot run `amy` against live relays from this environment, the fixture + * is generated in code from a fixed RNG seed. The shape mirrors real-world + * home-feed payloads: + * + * - one owner ViewOnly account, + * - kind:10002 advertised-relay list, + * - kind:3 contact list with 50 follows, + * - kind:0 metadata for each followee, + * - 50 kind:1 text notes from a mix of followees over the last 7 days. + * + * Switching to a real-world fixture later is a drop-in: replace + * [LaunchFixture.events] with a parser of `*.jsonl` resource files. The + * server, builder, and benchmark harness do not care about the source. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 2.3. + */ +class LaunchFixture private constructor( + val ownerKeyPair: KeyPair, + val events: List, +) { + val ownerPubKeyHex: String get() = ownerKeyPair.pubKey.toHexKey() + + companion object { + /** + * Builds the canonical home-feed snapshot for the benchmark/test scenarios. + * + * The seed is fixed so successive calls produce byte-identical fixtures + * — important for benchmark reproducibility — but a caller can pass + * a different seed when probing edge cases. + */ + fun build( + seed: Long = SEED, + followCount: Int = FOLLOW_COUNT, + noteCount: Int = NOTE_COUNT, + nowSeconds: Long = FIXED_NOW, + ): LaunchFixture { + val rng = SeededRng(seed) + val owner = keyPairFromSeed(rng.nextLong()) + val ownerSigner = NostrSignerSync(owner) + + val followees = List(followCount) { keyPairFromSeed(rng.nextLong()) } + + val events = + buildList { + add(advertisedRelays(ownerSigner, nowSeconds)) + add(contactList(ownerSigner, followees, nowSeconds)) + followees.forEachIndexed { idx, kp -> + add(metadata(kp, idx, nowSeconds)) + } + repeat(noteCount) { i -> + val author = followees[rng.nextInt(followees.size)] + val ageSeconds = rng.nextLongInRange(MIN_AGE_SECONDS, MAX_AGE_SECONDS) + add(textNote(author, i, nowSeconds - ageSeconds)) + } + } + + return LaunchFixture(owner, events) + } + + private const val SEED = 0xA3F71E5L + private const val FOLLOW_COUNT = 50 + private const val NOTE_COUNT = 50 + + // Pin "now" so the fixture is fully deterministic across machines / + // timezones. The 7-day hydration window in LocalRelayStore is + // wall-clock based, so callers seeding events.db for warm-boot runs + // should pass a fresh `nowSeconds` to keep the events in window. + const val FIXED_NOW: Long = 1_750_000_000L + + private const val MIN_AGE_SECONDS = 60L * 5L // 5 minutes ago + private const val MAX_AGE_SECONDS = 60L * 60L * 24L * 2L // 2 days ago + + private fun keyPairFromSeed(seed: Long): KeyPair { + // Derive a 32-byte private key deterministically from the seed. + // Avoids depending on system entropy and keeps the fixture stable. + val key = ByteArray(32) + var v = seed.toULong() xor 0x9E3779B97F4A7C15uL + for (i in 0 until 32) { + v = v * 6364136223846793005uL + 1442695040888963407uL + key[i] = ((v shr 56).toInt() and 0xFF).toByte() + } + // secp256k1 valid private key range: 1..n-1. Force into a known-good range + // by clamping the top byte; n's top byte is 0xFF FF FF FF FE BA AE DC E6. + key[0] = (key[0].toInt() and 0x7F).toByte() + if (key.all { it == 0.toByte() }) key[31] = 1 + return KeyPair(privKey = key) + } + + private fun advertisedRelays( + signer: NostrSignerSync, + nowSeconds: Long, + ): Event = + signer.sign( + createdAt = nowSeconds - 60, + kind = AdvertisedRelayListEvent.KIND, + tags = + arrayOf( + arrayOf("r", "wss://test.invalid"), + ), + content = "", + ) + + private fun contactList( + signer: NostrSignerSync, + followees: List, + nowSeconds: Long, + ): Event { + val tags = followees.map { arrayOf("p", it.pubKey.toHexKey()) }.toTypedArray() + return signer.sign( + createdAt = nowSeconds - 30, + kind = ContactListEvent.KIND, + tags = tags, + content = "", + ) + } + + private fun metadata( + keyPair: KeyPair, + index: Int, + nowSeconds: Long, + ): Event { + val signer = NostrSignerSync(keyPair) + return signer.sign( + createdAt = nowSeconds - 3600 - index * 7L, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = """{"name":"Test User $index","about":"Synthetic launch fixture user","picture":""}""", + ) + } + + private fun textNote( + keyPair: KeyPair, + index: Int, + createdAt: Long, + ): Event { + val signer = NostrSignerSync(keyPair) + return signer.sign( + createdAt = createdAt, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "Synthetic note #$index — fixed text so first paint timing is stable.", + ) + } + } +} + +/** + * Tiny xorshift64* PRNG. Deterministic, fast, no platform deps. Used only + * by the fixture builder. + */ +private class SeededRng( + seed: Long, +) { + private var state: ULong = (if (seed == 0L) 1L else seed).toULong() + + fun nextLong(): Long { + var x = state + x = x xor (x shr 12) + x = x xor (x shl 25) + x = x xor (x shr 27) + state = x + return (x * 2685821657736338717uL).toLong() + } + + fun nextInt(bound: Int): Int { + require(bound > 0) + val v = nextLong() ushr 1 + return (v % bound.toLong()).toInt() + } + + fun nextLongInRange( + from: Long, + until: Long, + ): Long { + require(until > from) + val span = until - from + val v = nextLong() ushr 1 + return from + (v % span) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelay.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelay.kt new file mode 100644 index 0000000000..dc02ee1a90 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelay.kt @@ -0,0 +1,88 @@ +/* + * 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.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking + +/** + * In-process relay primed with a [LaunchFixture] (or any list of pre-signed + * events). Wraps an [EventStore] backed [NostrServer], applies + * [EmptyPolicy] so REQs are answered without auth gating, and exposes the + * matching [InProcessWebsocketBuilder] so a [RelayConnectionManager] / test + * harness can connect to it. + * + * Lifecycle: callers should construct via [open], use, then [close]. + * The constructor seeds the store synchronously to keep test setup terse. + * + * Combines plan Phases 2.1 (builder) + 2.2 (server) + 2.4 (wire) into a + * single small entry point used by every consumer. + * + * The relay URL [LAUNCH_TEST_RELAY_URL] is what tests should add to the + * account's relay list — the in-process socket ignores the URL value, but + * RelayPool keys connections by it. + */ +class LaunchFixtureRelay private constructor( + val server: NostrServer, + private val store: EventStore, +) : AutoCloseable { + val builder: WebsocketBuilder = InProcessWebsocketBuilder(server) + + override fun close() { + // NostrServer.close() also closes its store. + server.close() + } + + companion object { + val LAUNCH_TEST_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("wss://launch.test.invalid") + + /** + * Construct a relay pre-seeded with [events]. The seeding is done + * synchronously via [runBlocking] — fine in a JVM test context but + * never call from production code. + */ + fun open( + events: List, + dispatcher: CoroutineDispatcher = Dispatchers.Default, + ): LaunchFixtureRelay { + val store = EventStore(dbName = null, relay = LAUNCH_TEST_RELAY_URL) + runBlocking { store.batchInsert(events) } + val server = + NostrServer( + store = store, + policyBuilder = { EmptyPolicy }, + parentContext = dispatcher + SupervisorJob(), + ) + return LaunchFixtureRelay(server, store) + } + + /** Convenience: open a relay primed with [LaunchFixture.build]. */ + fun openLaunchFixture(): LaunchFixtureRelay = open(LaunchFixture.build().events) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt new file mode 100644 index 0000000000..618704a8ed --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt @@ -0,0 +1,112 @@ +/* + * 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.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +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.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Phase 2 roundtrip: NostrClient ↔ InProcessWebsocketBuilder ↔ NostrServer + * with a seeded fixture round-trips a REQ → EVENTs → EOSE and delivers + * the same events the fixture contains. + * + * Uses real coroutine dispatchers (not `runTest` virtual time) because the + * in-process websocket pumps events on real channels — virtual time would + * never advance the sample-debounce inside `NostrClient.allRelays`. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 2.4. + */ +class LaunchFixtureRelayTest { + @Test + fun reqAgainstFixtureRelayReturnsAllAuthorNotesThenEose() = + runBlocking { + val fixture = LaunchFixture.build(noteCount = 10) + val relay = LaunchFixtureRelay.open(fixture.events) + val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + try { + val client = NostrClient(relay.builder, parentScope = clientScope) + client.connect() + + val received = mutableListOf() + val eose = CompletableDeferred() + + client.subscribe( + subId = "test-sub", + filters = + mapOf( + LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), + ), + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + received += event + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eose.complete(Unit) + } + }, + ) + + withTimeout(5.seconds) { eose.await() } + + val expectedNotes = fixture.events.count { it.kind == TextNoteEvent.KIND } + assertEquals( + expectedNotes, + received.size, + "Fixture relay must replay every text note before EOSE", + ) + assertTrue( + received.all { it.kind == TextNoteEvent.KIND }, + "REQ kind:[1] must only yield kind:1 events", + ) + client.close() + } finally { + relay.close() + clientScope.cancel() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt new file mode 100644 index 0000000000..2720941b42 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt @@ -0,0 +1,108 @@ +/* + * 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.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +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.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Pins the invariant required by the Phase 5.2 launch-optimization fix: + * [NostrClient.subscribe] called BEFORE [NostrClient.connect] still + * delivers events once the connection comes up. This is the basis for + * dropping the `connectedRelays.first { isNotEmpty() }` gate from the + * desktop bootstrap subscription (Main.kt:1242-1284). + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 5.2. + */ +class SubscribeBeforeConnectTest { + @Test + fun subscribeIssuedBeforeConnectStillReceivesEventsAndEose() = + runBlocking { + val fixture = LaunchFixture.build(noteCount = 5) + val relay = LaunchFixtureRelay.open(fixture.events) + val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + try { + val client = NostrClient(relay.builder, parentScope = clientScope) + + val received = mutableListOf() + val eose = CompletableDeferred() + + // Subscribe BEFORE connect — the production fix that drops the + // `connectedRelays.first { isNotEmpty() }` gate relies on REQs + // queued at this point being flushed when the pool comes up. + client.subscribe( + subId = "pre-connect-sub", + filters = + mapOf( + LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), + ), + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + received += event + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eose.complete(Unit) + } + }, + ) + + client.connect() + + withTimeout(5.seconds) { eose.await() } + + val expected = fixture.events.count { it.kind == TextNoteEvent.KIND } + assertTrue( + received.size == expected, + "Subscription registered pre-connect should still deliver all $expected events (got ${received.size})", + ) + client.close() + } finally { + relay.close() + clientScope.cancel() + } + } +}