feat(desktop): in-process relay seam, launch benchmark, bootstrap-gate removal

Phases 2.1/2.2/2.3, 3.1/3.2, 4, 5.2, and 6 of the launch-optimization plan
land together because they share a single set of seams and a single
benchmark report.

* InProcessWebsocketBuilder + LaunchFixtureRelay wrap quartz's existing
  InProcessWebSocket + NostrServer (with EmptyPolicy) so any test can
  drive a NostrClient against an in-memory relay seeded with arbitrary
  events. Roundtrip verified by LaunchFixtureRelayTest.

* LaunchFixture builds a deterministic 50-note synthetic home-feed
  snapshot from a fixed RNG seed (kind:1 + author kind:0 + kind:3 +
  kind:10002). A real-world JSONL artifact is a drop-in replacement.

* NoteCard gets a stable testTag + a CompositionLocal-backed
  onPlaced hook. Production overhead is one composition-local read
  plus one null check per placement (default
  LocalNoteCardInstrumentation = null).

* LaunchMarkers records named markers against TimeSource.Monotonic.
  LaunchScenario.coldBoot drives the AccountManager (ViewOnly path)
  + DesktopLocalCache + RelayConnectionManager + LocalRelayStore
  stack against the fixture relay and reports t_account_logged_in,
  t_first_event, t_n_events.

* LaunchBenchmark runs 2 warmup + 5 measured iterations, computes
  min/q1/median/q3/max, atomically writes the report file, and is
  skipped by default — opt in via AMETHYST_BENCH=true. Baseline +
  post-fix snapshots committed under desktopApp/benchmarks/.

* SubscribeBeforeConnectTest proves NostrClient / RelayPool queue REQs
  issued before connect() and flush them when the connection comes up.
  The bootstrap-config subscription in Main.kt drops its
  `connectedRelays.first { isNotEmpty() }` + 30s withTimeoutOrNull gate
  on the strength of that invariant — the subscription now fires
  eagerly and recovers when no relay ever connects instead of silently
  giving up after 30s.

All 274 desktopApp tests pass. No flaky tests introduced.
This commit is contained in:
nrobi144
2026-06-18 11:10:14 +03:00
parent 48726ee4df
commit b14ee5ec5c
15 changed files with 1301 additions and 56 deletions

View File

@@ -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-<git-sha>.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`.

View File

@@ -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]

View File

@@ -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]

View File

@@ -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

View File

@@ -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<Filter>?,
) {
// 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<Filter>?,
) {
// 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) }
}

View File

@@ -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,

View File

@@ -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<NoteCardInstrumentation?> =
compositionLocalOf { null }

View File

@@ -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<LaunchScenario.Result>): 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<Long>.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<Long>.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]
}

View File

@@ -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<String, Duration>()
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<String, Duration> = 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<String>()
return NoteCardInstrumentation { noteId ->
if (seen.add(noteId)) {
val placed = noteCardCounter.incrementAndGet()
when {
placed == 1 -> mark(T_FIRST_EVENT)
placed >= n -> mark(T_N_EVENTS)
}
}
}
}
}

View File

@@ -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<String, Duration>,
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<SecureKeyStorage>(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<Unit>()
val nReached = CompletableDeferred<Unit>()
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<Filter>?,
) {
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<Filter>?,
) {
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<Unit> {
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)

View File

@@ -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)
}

View File

@@ -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<Event>,
) {
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<AdvertisedRelayListEvent>(
createdAt = nowSeconds - 60,
kind = AdvertisedRelayListEvent.KIND,
tags =
arrayOf(
arrayOf("r", "wss://test.invalid"),
),
content = "",
)
private fun contactList(
signer: NostrSignerSync,
followees: List<KeyPair>,
nowSeconds: Long,
): Event {
val tags = followees.map { arrayOf("p", it.pubKey.toHexKey()) }.toTypedArray()
return signer.sign<ContactListEvent>(
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<MetadataEvent>(
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<TextNoteEvent>(
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)
}
}

View File

@@ -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<Event>,
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)
}
}

View File

@@ -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<Event>()
val eose = CompletableDeferred<Unit>()
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<Filter>?,
) {
received += event
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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()
}
}
}

View File

@@ -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<Event>()
val eose = CompletableDeferred<Unit>()
// 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<Filter>?,
) {
received += event
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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()
}
}
}