Files
amethyst/desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
T
nrobi144 96752bd21b docs(desktop): mark all in-scope launch-opt phases complete in the plan
Phase 1.4 (App() smoke test), Phase 2.4 (fixture-relay wire-up), and
Phase 5.2 (bootstrap-gate fix + regression tests) all land in commit
48a8178c9. The progress-log table, acceptance-criteria checkboxes, and
pending-work section are updated to reflect the new state. 278/278
desktopApp tests pass.

Only follow-up enhancements remain — the cold-fork shell driver, a
Compose-driving benchmark variant, and Phase 5.3 (sequential remember
chain in MainContent). None are required for the in-scope set.
2026-06-18 12:10:58 +03:00

689 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: App Launch Optimization (Desktop, Foundation-first)
type: feat
status: active
date: 2026-06-17
origin: docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md
deepened: 2026-06-17
---
# App Launch Optimization (Desktop, Foundation-first)
## 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` |
| 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` | `b14ee5ec5` |
| 2026-06-18 | 1.4 / 2.4 / 5.2-tests | `LaunchTestOverrides` makes `relayManager` / `localCache` / `localRelayStore` / `torSettings` injectable into `App()`. `DesktopRelayConnectionManager` gains a secondary ctor taking a `WebsocketBuilder` so the fixture relay can substitute without relaxing `LocalRelayManager`'s composition-local type. `AppStateMachineTest` ships four tests: logged-out → LoginScreen; ViewOnly preloaded → LoggedIn; bootstrap-gate-removal verified against `NeverConnectsWebsocketBuilder`; no double-fire of the bootstrap REQ via `RecordingWebsocketBuilder`. | `48a8178c9` |
**All foundational and in-scope phases (1.1, 1.2, 1.3, 1.4, 2.1-2.4, 3.1, 3.2, 4, 5.1, 5.2, 6) are now landed.** 278/278 desktopApp tests pass.
**Deferred to follow-up plans (not part of this effort):**
- **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 benchmark variant** (wires `LaunchMarkers` to
`LocalNoteCardInstrumentation` and drives `App()` via the harness
introduced for Phase 1.4 so the markers include the real composition +
layout cost, not just the relay/cache pipeline).
- **Phase 5.3** (sequential `remember` chain in `MainContent`) — a
separate plan; only worth tackling if a real cold-boot profiler trace
shows `MainContent` composition is on the critical path.
- **Memory + warm-boot benchmark variants** — once the cold-fork driver
and Compose-driving variant land, layering memory + pre-seeded `events.db`
warm-boot is straightforward.
## Enhancement Summary
**Deepened:** 2026-06-17 — 5 review agents (code-simplicity, architecture-strategist, performance-oracle, pattern-recognition, spec-flow-analyzer) plus repo-research-analyst.
**Key changes from initial plan:**
1. **Plan relocated** from `docs/plans/` (frozen per CLAUDE.md) to `desktopApp/plans/`.
2. **Phase 5.3 (sequential `remember` chain) cut** — spawn separate plan if Phase 4 baseline justifies. Avoids investigation scope creep.
3. **Warm-boot benchmark variant deferred** — all in-scope fixes target cold-boot; defer until a warm-path fix appears.
4. **Memory metric deferred** — `runComposeUiTest` heap isn't representative of real Swing/Skia; JVM `System.gc()` semantics unreliable. Revisit after baseline.
5. **Test fixtures placed in `:quartz` testFixtures** (not `:commons`) — `:quartz` already proves KMP + `java-test-fixtures` works (consumes `:geode` fixtures at `quartz/build.gradle.kts:352-358`). `:commons` KMP attempt was unnecessary risk.
6. **Use `createComposeRule()`** (not `runComposeUiTest`) — repo convention per `DesktopLaunchSmokeTest.kt:24`.
7. **Repo's existing test pattern**: `backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { ... toList(states) } + advanceUntilIdle()` (per `AccountManagerStateTransitionTest.kt:73-95`).
8. **Cold benchmark forks JVM per sample** (shell-script driver) — same-JVM iterations measure JIT warmth, not cold boot.
9. **Renamed `t_compose_first_frame` → `t_first_composition_apply`** with explicit "not a real Skia frame" disclaimer.
10. **Event-count instrumentation via `Modifier.onPlaced` + `AtomicInteger`** — semantic-tree polling has 16ms quantization + tree-traversal bias.
11. **Pinned JVM flags** (`-Xms512m -Xmx512m -XX:+UseG1GC`); control benchmark (empty `Box`) measures harness floor.
12. **N=20 warm iterations, median + IQR + min** — Mann-Whitney U for fix delta significance, not t-test.
13. **`AccountManagerLoadStateTransitionsTest`** renamed to avoid collision with existing `AccountManagerStateTransitionTest`.
14. **Cut Internal/Remote AccountManager tests** — only ViewOnly drives the benchmark; deferred coverage is a separate scope.
15. **Cut `tools/launch-fixture/capture.sh`** — manual one-shot fixture commit; document `amy` commands in README.
### New Considerations Discovered
- `:quartz` already consumes `:geode` testFixtures across KMP boundary — KMP+testFixtures friction is overstated.
- `runComposeUiTest` skips real Swing/Skia surface; `t_first_composition_apply` measures composition cost, not paint.
- `RelayPool` queue-pre-connect behavior is the hinge for Phase 5.2 candidate (a) vs (b); must verify before refactor.
- Both home + DM subscriptions share the same gate (`Main.kt:1283-1326` + `1330`); fix must share a helper.
- `InProcessWebSocket` latency floor (~10-50ms) may swallow icon-decode delta on `t_n_events`; microbench the ImageIO.read cost directly.
---
## Overview
Build the **testing + benchmarking foundation** needed to safely refactor Amethyst Desktop's launch path, capture a quantitative baseline, then ship two targeted launch-path fixes guided by the resulting numbers. Android is explicitly deferred to a follow-up plan; sequential `remember` chain refactor (originally Phase 5.3) is deferred to a separate plan post-baseline.
Phased delivery (Approach A from brainstorm):
1. **Test pyramid foundation** — unit tests around `AccountManager.loadSavedAccount` (ViewOnly path only) + `LocalRelayStore.hydrate`; three `App()`-level Compose UI smoke tests. No launch-path code change beyond an optional `homeDir` ctor param on `LocalRelayStore`.
2. **Deterministic relay seam** — `InProcessWebsocketBuilder` + `FixtureNostrServer` in `:quartz` testFixtures. Real-world snapshot fixture (50 kind:1 + metadata for one well-known npub).
3. **Benchmark harness** — JVM-only harness driving `App()` via `createComposeRule` + onPlaced markers. Two harnesses: cold (fork-per-sample shell driver, N=10) and warm (single JVM, N=20). Memory + warm-boot DEFERRED.
4. **Baseline capture** — run benchmark on `main`, commit numbers as the reference point.
5. **Targeted fixes** — icon decode (cheap, microbench independent), feed bootstrap relay gate (medium refactor); each re-benchmarked after.
(see brainstorm: `docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md`)
---
## Problem Statement
Cold-boot perception is a primary UX signal and currently **unmeasured**. No timing markers exist in `Main.kt`, `App()`, or `AccountManager.loadSavedAccount`. The desktop launch path has identified bottlenecks (`Main.kt:218,302,988` triple icon decode on main thread; `Main.kt:1283-1330` home + DM subscriptions both gated on `connectedRelays.first { isNotEmpty() }` with a 30s timeout) — but refactoring boot code without tests is exactly how regressions ship.
The only existing test that touches launch wiring is `DesktopLaunchSmokeTest.kt:24` (drives `LoginScreen` only via `createComposeRule`); nothing covers `App()`, `MainContent`, or the `AccountState` transitions. We need the safety net **before** the refactors.
---
## Proposed Solution
Foundation-first: write tests before touching launch code. Inject a deterministic in-memory relay via the already-open `WebsocketBuilder` constructor seam on `RelayConnectionManager` (`Main.kt:834`). Drive the read-only npub flow (`AccountManager.loadReadOnlyAccount` — pure, no I/O, no keychain) as the headline benchmark scenario. Measure four time metrics (composition apply, account hydration done, first event, N=10 events; memory DEFERRED). Capture cold (fork-per-sample) and warm (single-JVM N=20) runs locally. Then fix one bottleneck at a time and prove the delta with numbers.
Key insight (deepen-plan): **most seams already exist.** `RelayConnectionManager` is `open class` taking `WebsocketBuilder`. `App()` is window-agnostic. Quartz already ships `InProcessWebSocket` + `NostrServer`. `:quartz` already consumes `:geode` testFixtures across the KMP boundary. The brainstorm's "FakeWebsocketBuilder" is a 20-line wrapper.
---
## Technical Approach
### Architecture
```
Test JVM (jvmTest)
├── LaunchMarkers (single-threaded, mutableMapOf<String, Duration>)
├── createComposeRule { setContent { MaterialTheme { App(...) } } }
│ └── App() with injected deps:
│ ├── AccountManager — temp homeDir, no keychain (ViewOnly only)
│ ├── LocalRelayStore — temp homeDir param (new ctor arg)
│ ├── DesktopRelayConnectionManager — InProcessWebsocketBuilder(fakeServer)
│ └── LocalRelayMaintenance.start() suppressed in tests (no refactor)
├── InProcessWebsocketBuilder (:quartz testFixtures)
│ └── wraps InProcessWebSocket → FixtureNostrServer
│ └── loads fixtures/launch/<name>.jsonl, matches REQ via Filter.match
└── Fixture: quartz/src/testFixtures/resources/fixtures/launch/fiatjaf-50.jsonl
```
### Module Placement
- `:quartz` testFixtures: `InProcessWebsocketBuilder`, `FixtureNostrServer`, fixture JSONL.
- `:desktopApp:jvmTest`: `AccountManagerLoadStateTransitionsTest`, `LocalRelayStoreHydrationTest`, `AppStateMachineTest`, `LaunchMarkers`, `LaunchBenchmark`.
- `:commons/commonMain`: `NoteCardTags` constants object (so Android can reuse).
- `:desktopApp:jvmMain`: `NoteCard` adds `Modifier.testTag(NoteCardTags.ROOT).onPlaced { ... }` — one-line change with negligible production cost.
Consumer wiring: `desktopApp/build.gradle.kts` adds `jvmTestImplementation(testFixtures(project(":quartz")))`. Pattern proven at `quartz/build.gradle.kts:352-358` consuming `testFixtures(project(":geode"))`.
### Marker Registry
```kotlin
// desktopApp/src/jvmTest/kotlin/.../benchmark/LaunchMarkers.kt
object LaunchMarkers {
private val timestamps = mutableMapOf<String, Duration>()
private var start: TimeMark? = null
fun start() { timestamps.clear(); start = TimeSource.Monotonic.markNow() }
fun mark(name: String) {
if (name !in timestamps) timestamps[name] = start!!.elapsedNow()
}
fun snapshot(): Map<String, Duration> = timestamps.toMap()
}
```
Single-threaded — Gradle test parallelism disabled for benchmark suite (`maxParallelForks = 1` filtered to `*LaunchBenchmark*`).
Observation points (all in test code, zero production instrumentation):
- **`t_first_composition_apply`** — first `composeTestRule.waitForIdle()` returns after `setContent`. (Renamed from `t_compose_first_frame` — does NOT represent real Skia/GPU frame.)
- **`t_account_logged_in`** — observed via `backgroundScope.launch(UnconfinedTestDispatcher) { accountManager.accountState.toList(states) }` — marker recorded when `AccountState.LoggedIn` arrives.
- **`t_first_event`** — `Modifier.onPlaced` callback on `NoteCard` increments an `AtomicInteger`; marker recorded when counter goes 0→1.
- **`t_n_events`** — same counter reaches 10.
NoteCard production change is small:
```kotlin
// commons/src/commonMain/.../ui/note/NoteCardTags.kt (new)
object NoteCardTags {
const val ROOT = "amethyst.note_card.root"
}
// desktopApp/src/jvmMain/.../ui/note/NoteCard.kt:97
fun NoteCard(...) {
Surface(modifier = Modifier
.testTag(NoteCardTags.ROOT)
.onPlacedHook() // no-op in production, calls LaunchInstrumentation in tests
...) { ... }
}
```
`onPlacedHook` is a `Modifier` extension defined in `commons/commonMain` that's a no-op by default; tests swap it via a CompositionLocal `LocalLaunchInstrumentation` (default = `LaunchInstrumentation.Noop`). One-line override in `AppStateMachineTest` and `LaunchBenchmark`.
### Fake Relay Wire Path
```
Test sets up: FixtureNostrServer(fixture = "fiatjaf-50.jsonl")
↓
Test constructs: DesktopRelayConnectionManager(InProcessWebsocketBuilder(server))
↓
App() → RelayConnectionManager → NostrClient → RelayPool → BasicRelayClient
↓ (uses InProcessWebsocketBuilder)
InProcessWebSocket connects to FixtureNostrServer
↓
Test sets account.relays to listOf(NormalizedRelayUrl("wss://test.invalid"))
↓
NostrClient sends REQ frames; FixtureNostrServer matches by Filter, replays EVENTs + EOSE
↓
DesktopLocalCache.consume(event, relay = "wss://test.invalid", wasVerified = true)
↓
LocalFeedProvider observes; FeedScreen LazyColumn composes NoteCards
↓
Modifier.onPlaced fires per NoteCard, AtomicInteger inc, LaunchMarkers records t_first_event then t_n_events
```
### Implementation Phases
#### Phase 1 — Test pyramid foundation
**1.1 — `AccountManagerLoadStateTransitionsTest` (ViewOnly only)**
New file: `desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt`
Renamed to avoid collision with existing `AccountManagerStateTransitionTest.kt`. Covers ViewOnly happy path + one decode-failure path (corrupt `accounts.json.enc`). Internal + Remote deferred — they are unrelated to the benchmark, and adding them is scope creep.
Pattern follows repo convention (`AccountManagerStateTransitionTest.kt:73-95`):
```kotlin
@Test
fun viewOnlyAccountTransitionsThroughLoadingToLoggedIn() = runTest {
val storage = mockk<SecureKeyStorage>(relaxed = true)
val tempDir = createTempDirectory("acctmgr-load-state").toFile()
writeAccountsJsonEnc(tempDir, viewOnlyAccountInfo(testNpub))
val mgr = AccountManager(storage, tempDir)
val states = mutableListOf<AccountState>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
mgr.accountState.toList(states)
}
mgr.loadSavedAccount()
advanceUntilIdle()
assertTrue(states.size >= 3)
assertTrue(states.first() is AccountState.Loading)
assertTrue(states.last() is AccountState.LoggedIn)
assertEquals(true, (states.last() as AccountState.LoggedIn).isReadOnly)
}
```
Acceptance: 2 tests pass (happy + decode failure).
**1.2 — `LocalRelayStoreHydrationTest`**
Covers `LocalRelayStore.hydrate` invariants (`LocalRelayStore.kt:115-161`). 5 tests:
- kind:3 (contact list) consumed before kind:0 (metadata).
- kind:0 author metadata before activity events.
- All consumed events tagged with `LOCAL_RELAY_URL` + `wasVerified=true`.
- Empty DB hydrates without throwing; cache receives no events.
- Replaceable (kind:0/3): only most recent kept.
**1.3 — `LocalRelayStore` `homeDir` ctor seam**
File: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt`
```kotlin
class LocalRelayStore(
private val scope: CoroutineScope,
private val homeDir: File = File(System.getProperty("user.home")),
) : AutoCloseable {
private fun dbDir(pubKeyHex: String): File =
File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
...
}
```
`LocalRelayMaintenance` refactor **deferred** — tests just don't call `maintenance.start()`. If/when it becomes a blocker, refactor then.
**1.4 — `AppStateMachineTest` (Compose UI smoke)**
New file: `desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt`
Uses `createComposeRule()` (repo convention). Wraps `MaterialTheme {}` per `DesktopLaunchSmokeTest.kt:69` pattern. Three tests:
- `loggedOutShowsLoginScreen()` — no `accounts.json.enc`.
- `viewOnlyAccountReachesLoggedIn()` — pre-write ViewOnly account.
- `forceLogoutReasonShowsDialog()` — pre-populate `forceLogoutReason`.
Temporary minimal `WebsocketBuilder` fake (replaced by `InProcessWebsocketBuilder` in 2.4).
Acceptance: 3 tests pass, ≤ 5s each.
#### Phase 2 — Deterministic relay seam
**2.1 — `InProcessWebsocketBuilder` in `:quartz` testFixtures**
Add `java-test-fixtures` plugin + testFixtures source set to `:quartz` if not already. (Verify: research found `quartz/build.gradle.kts:352-358` already consumes `testFixtures(project(":geode"))`, so the plugin DSL is in use somewhere in the chain — may need to be enabled on `:quartz` for it to produce fixtures.)
New file: `quartz/src/testFixtures/kotlin/com/vitorpamplona/quartz/test/relay/InProcessWebsocketBuilder.kt`
```kotlin
class InProcessWebsocketBuilder(
private val server: NostrServer,
) : WebsocketBuilder {
override fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket =
InProcessWebSocket(url, out, server)
}
```
Unit test verifies `RelayPool(builder).request(...)` round-trips REQ → EVENT + EOSE within 100ms.
**2.2 — `FixtureNostrServer`**
New file: `quartz/src/testFixtures/kotlin/com/vitorpamplona/quartz/test/relay/FixtureNostrServer.kt`
Spec (edge cases from spec-flow review baked in):
- Load fixture JSONL at construction; fail fast with `FixtureParseException(line, lineNumber, cause)` on malformed line.
- On REQ: match `Filter` per fixture event via `Filter.match(event)`. Send matching events in fixture order.
- **Always send EOSE** after replay, even if no events match (prevents benchmark hang).
- Per-connection `Mutex` to serialize REQ handling — safe under concurrent home + DM subs.
- Configurable response delay (default 5ms) to model relay RTT.
Tests: 50-event fixture + Filter for kinds=[1] authors=[pubKey] → 50 events + EOSE within 100ms; malformed line → `FixtureParseException`; empty match → bare EOSE.
**2.3 — Real-world snapshot fixture (manual capture)**
Per simplicity reviewer: no capture script. One-shot manual capture using `amy`. Document the exact commands in `quartz/src/testFixtures/resources/fixtures/launch/README.md`:
```bash
amy fetch --kinds 1 --author <fiatjaf-npub> --limit 50 --json > fiatjaf-50.jsonl
amy fetch --kinds 0 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
# For each p-tag referenced pubkey:
amy fetch --kinds 0 --author <ref-npub> --json >> fiatjaf-50.jsonl
amy fetch --kinds 3 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
amy fetch --kinds 10002 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
```
Commit `fiatjaf-50.jsonl` as an immutable artifact. Re-capture only via explicit human action; PR that recaptures must include new baseline numbers.
Acceptance: fixture present, ≥ 50 events, all valid signatures, FixtureNostrServer loads it without error.
**2.4 — Replace temporary fake in 1.4 tests**
Update `AppStateMachineTest.viewOnlyAccountReachesLoggedIn()` to use `InProcessWebsocketBuilder(FixtureNostrServer.load("fiatjaf-50.jsonl"))`. Validates full subscription wiring end-to-end.
Acceptance: existing Phase 1.4 tests still pass with real fixture data, no time blow-up (≤ 8s).
#### Phase 3 — Benchmark harness
**3.1 — `LaunchMarkers` + instrumentation hooks**
- `LaunchMarkers` (simple, single-threaded, per above).
- `NoteCardTags` const object in `commons/commonMain`.
- `LocalLaunchInstrumentation` CompositionLocal in `commons/commonMain` (default = `Noop`).
- `Modifier.onPlacedHook()` extension reading the CompositionLocal.
- NoteCard adds `.testTag(NoteCardTags.ROOT).onPlacedHook()` to its root surface (one line each, prod-safe).
Acceptance: launching `App()` against fixture relay produces 4 named markers within 10s.
**3.2 — Two benchmark harnesses (cold + warm)**
Per perf reviewer: cold and warm need different statistical regimes.
**Cold harness** — `desktopApp/benchmarks/cold-launch.sh`:
```bash
#!/usr/bin/env bash
ITERATIONS=${1:-10}
mkdir -p desktopApp/build/benchmarks
out=desktopApp/build/benchmarks/cold-$(git rev-parse --short HEAD).tmp
echo "# cold-launch $(date -u +%FT%TZ) $(java --version | head -1)" > "$out"
echo "# host: $(uname -a)" >> "$out"
echo "# jvm-flags: -Xms512m -Xmx512m -XX:+UseG1GC" >> "$out"
for i in $(seq 1 "$ITERATIONS"); do
./gradlew --no-daemon :desktopApp:jvmTest \
--tests "*ColdLaunchBenchmark.runOnce" \
-Dorg.gradle.jvmargs="-Xms512m -Xmx512m -XX:+UseG1GC" \
-Pbench.output="$out"
done
mv "$out" "${out%.tmp}.txt"
desktopApp/benchmarks/report.py "${out%.tmp}.txt"
```
Each iteration forks a fresh JVM (no daemon). `ColdLaunchBenchmark.runOnce` appends one row per metric (median is just the value; no in-JVM warmup). Output is moved atomically once complete (spec-flow gap #12 — atomic write).
**Warm harness** — `WarmLaunchBenchmark` JUnit test, single JVM:
- N=20 iterations within one test method.
- Discard first 5 iterations (JIT warmup).
- Report median + IQR + min on remaining 15.
- Atomic write to `desktopApp/build/benchmarks/warm-${git_sha}.txt`.
Both harnesses include a **control benchmark**: `setContent { Box {} }` with onPlaced + LaunchMarkers, to measure harness floor. Reported alongside; reviewers can read SNR.
`desktopApp/benchmarks/report.py` parses output, computes Mann-Whitney U vs. baseline if present, prints summary table.
Acceptance:
- Cold harness completes in ≤ 4 min (10 × ~25s/fork) on a developer laptop.
- Warm harness completes in ≤ 2 min.
- Output files include git SHA, JVM version, OS, host info in header.
- Control benchmark < 50ms on warm; documented as the harness floor.
**3.3 (DEFERRED) — Memory metric.** Not part of this plan. JVM heap post-`System.gc()` is unreliable; `TestComposeWindow` heap not representative of real Swing/Skia; out of scope.
**3.4 (DEFERRED) — Warm-boot variant (pre-seeded events.db).** Not part of this plan. None of the in-scope fixes target warm-path code; revisit when a warm-path fix appears.
#### Phase 4 — Baseline capture
Run `desktopApp/benchmarks/cold-launch.sh 10` and `./gradlew :desktopApp:jvmTest --tests "WarmLaunchBenchmark"` on a clean `main`. Commit both output files as:
- `desktopApp/benchmarks/baseline-main-cold.txt`
- `desktopApp/benchmarks/baseline-main-warm.txt`
Append a "Baseline" section to this plan with numbers. No code change.
Acceptance: baselines committed, plan updated, deltas on re-run ≤ 15% (median-to-median).
#### Phase 5 — Targeted fixes
Each lands in its own PR with before/after numbers in the description.
**5.1 — Icon decode**
Files: `desktopApp/src/jvmMain/.../Main.kt:218, 302, 988`.
Top-level shared lazy:
```kotlin
// desktopApp/src/jvmMain/.../IconResources.kt (new)
val DesktopAppIcon: BufferedImage by lazy {
requireNotNull(IconResources::class.java.getResourceAsStream("/icon.png")) {
"icon.png not found"
}.use { ImageIO.read(it) }
}
```
Replace all three sites. Default `lazy` mode (`SYNCHRONIZED`) is fine — three call sites span main + AWT threads.
**Microbench (separate from end-to-end):** add `IconResourcesBenchmark` running `ImageIO.read` N=1000 times to measure absolute decode cost. Report savings independently in case `InProcessWebSocket` floor (perf reviewer #6) hides the delta on `t_n_events`.
Unit test: `IconResourcesTest` asserts `ImageIO.read` invoked once per process (counter wrapper around `getResourceAsStream`).
Re-run benchmark: expected delta = (decode_cost × 2). Microbench delta = decode_cost × 999.
**5.2 — Feed bootstrap relay gate**
Files: `desktopApp/src/jvmMain/.../Main.kt:1283-1326, 1330` (home + DM subs).
**Investigation step (in the PR):** check whether `RelayPool` already queues REQs pre-connect (`quartz/.../RelayPool.kt`). Two outcomes:
- **(a) Pool queues:** delete the `connectedRelays.first { isNotEmpty() }` gate entirely. Subscriptions fire eagerly; pool flushes on connect. **Preferred.**
- **(b) Pool does not queue:** change predicate to `connectedRelays.first { any { it.state in setOf(CONNECTING, CONNECTED) } }`. Lower-risk fallback.
Reject (c) `combine + debounce(50ms)` — adds artificial 50ms to the critical path we're shortening (arch reviewer).
**Single shared helper** between home + DM gate (arch reviewer flag) — both must point at the same logic to avoid drift.
New tests (spec-flow + perf): all use Phase 2 fixture relay infrastructure.
- `slowRelayDoesNotStallFeed()` — `FixtureNostrServer(responseDelay = 1.seconds)`; bootstrap REQ flushes within 1.1s, not 30s.
- `noRelaysAvailableShowsErrorState()` — empty relay list; assert UI reports error within ≤ 10s.
- `relayListArrivesLateStartsSubscriptionThen()` — relay list emitted after `loadSavedAccount` returns; sub fires post-emit, not pre-emit.
- `relaysAddedMidLoadDoNotDoubleSubscribe()` — flip relay list during boot; verify single REQ.
Re-run benchmark: expected delta = large on `t_first_event` and `t_n_events` (gate currently blocks critical path).
**5.3 (CUT)** — sequential `remember` chain refactor. Out of scope this plan. If Phase 4 baseline shows `MainContent` composition cost > 50ms, spawn `desktopApp/plans/<date>-feat-mainContent-state-holder-refactor-plan.md` separately. Reason: scope creep risk + composite-holder choice depends on consumer audit.
---
## Alternative Approaches Considered
**B — Vertical slice per fix.** Per-bottleneck test+bench+fix. Rejected: contradicts "testing before refactor" priority; sequential `remember` lacks broader safety net. (see brainstorm § "Approach B")
**C — Instrument-first, fix-later.** Markers behind a flag; tests + refactors split into follow-ups. Rejected: same reason. (see brainstorm § "Approach C")
**Macrobenchmark / Android-first.** `androidx.benchmark.macro.StartupTimingMetric`. Deferred to a separate plan. (see brainstorm § "Q1 Platform Priority")
**Real WebSocket loopback to a test relay (Docker).** Rejected: network jitter + container startup; in-process is deterministic.
**JMH for benchmarks.** Rejected: doesn't compose with `createComposeRule`. Used independently for the icon microbench in Phase 5.1.
**Fakes in `:commons` testFixtures (original plan).** Rejected per arch review: `:commons` is KMP, unproven with `java-test-fixtures` here; `:quartz` already proves the pattern works.
**`testFixtures` for fakes vs hand-rolled constants.** Hand-rolled would re-invent `Filter.match`; `FixtureNostrServer` reuses existing logic. Keep testFixtures route.
**CompositionLocal `Instrumentation` interface.** Rejected per arch review: would add production API surface for a single test concern.
---
## System-Wide Impact
### Interaction Graph
Phase 1-4: production interaction graph **unchanged**. Tests observe production flows.
Phase 5 changes:
- **5.1 icon decode**: three `ImageIO.read` calls collapse to one `lazy`. `Coil`, Compose `Image`, `DesktopImageLoaderSetup` untouched. Thread safety: `lazy` SYNCHRONIZED default.
- **5.2 feed bootstrap gate**: `RelayConnectionManager` → `NostrClient` → `RelayPool` chain unchanged. The gating predicate in `Main.kt:1283-1326` shifts (or is removed). DM sub at `Main.kt:1330` uses same helper.
### Error & Failure Propagation
- Test errors: `LaunchMarkers.snapshot()` exposes recorded markers so far on any test failure for diagnostic clarity.
- Fixture errors: `FixtureNostrServer` throws on unknown filter and on malformed line at load — no silent empty EOSE.
- Production 5.2: REQ queued pre-connect (case a) relies on `RelayPool` retry semantics; verify before merge.
### State Lifecycle Risks
- Test isolation: each iteration uses fresh temp `homeDir` (cleaned via `@After` + `Files.walk(...).sorted(reverseOrder()).forEach(Files::delete)`).
- `Preferences.userRoot()` write in `LocalRelayMaintenance`: tests don't call `start()`. Production code path unchanged.
- Marker registry: single-threaded; benchmark suite serialized via Gradle `maxParallelForks = 1` filter on `*LaunchBenchmark*`.
- Fixture file stale: re-capture is manual; PR includes new numbers.
- Phase 5.2 risk: subscription firing before relay list emit — explicit `relayListArrivesLateStartsSubscriptionThen` test pins behavior.
### API Surface Parity
- `WebsocketBuilder` already stable across `RelayConnectionManager`, `NostrClient`, `RelayPool`. Test substitution touches no production code.
- `LocalRelayStore` ctor change is additive (default param). Existing callers compile unchanged.
- `NoteCardTags`/`testTag` additive; KDoc states "stable identifier for UI tests; do not key behavior off this."
### Integration Test Scenarios
Five scenarios unit tests with mocks won't catch:
1. **Cold boot ViewOnly → 10 events visible against fixture** — primary benchmark; end-to-end through AccountManager + LocalRelayStore + RelayConnectionManager + NostrClient + LocalCache + FeedScreen.
2. **Slow relay** (Phase 5.2 test) — fixture delays REQ response by 1s; assert subscription completion within 1.1s post-fix.
3. **Relay list arrives late** — exposes ordering bug if Phase 5.2 case (a) fires REQ before list populated.
4. **No relays available** — empty list; UI reports error rather than hang.
5. **Logged-out → LoginScreen** — no `accounts.json.enc`.
---
## Acceptance Criteria
### Functional Requirements
- [x] **Phase 1.1**: 2 tests (ViewOnly happy + decode failure) pass using repo's existing pattern (`backgroundScope.launch(UnconfinedTestDispatcher(testScheduler))` + `advanceUntilIdle()`). _Landed 2026-06-17 — `AccountManagerLoadStateTransitionsTest` (commit `ff55898ab`)._
- [x] **Phase 1.2**: 5 `LocalRelayStoreHydrationTest` cases pass. _Landed 2026-06-17 (commit `ff55898ab`)._
- [x] **Phase 1.3**: `LocalRelayStore` accepts optional `homeDir`; existing callers unchanged; `LocalRelayMaintenance` untouched. _Landed 2026-06-17 (commit `ff55898ab`)._
- [x] **Phase 1.4**: 4 `AppStateMachineTest` Compose UI tests pass via `createComposeRule()` with `MaterialTheme {}` wrap (commit `48a8178c9`). Slot was opened by adding `LaunchTestOverrides` to `App()` so `relayManager` / `localCache` / `localRelayStore` / `torSettings` can be injected, plus a secondary `DesktopRelayConnectionManager(WebsocketBuilder)` ctor that lets tests substitute the in-process fixture relay without relaxing the `LocalRelayManager` composition-local type. Production callers pass `null` and follow the existing `remember { … }` path.
- [ ] **Phase 2.1**: `InProcessWebsocketBuilder` in `quartz/src/testFixtures/`; round-trip test passes.
- [ ] **Phase 2.2**: `FixtureNostrServer` correctly matches `Filter`, always emits EOSE (incl. empty match), fails fast on malformed JSONL via `FixtureParseException`, per-connection Mutex.
- [ ] **Phase 2.3**: `fiatjaf-50.jsonl` committed under `quartz/src/testFixtures/resources/fixtures/launch/`, ≥ 50 events, all valid signatures; README documents recapture commands.
- [x] **Phase 2.4**: Phase 1.4 tests run against `LaunchFixtureRelay.open(fixture.events)` wired through the `LaunchTestOverrides.relayManager` field (commit `48a8178c9`).
- [ ] **Phase 3.1**: `LaunchMarkers` produces 4 named markers within 10s of a fixture cold boot. `NoteCardTags.ROOT` in `commons/commonMain`. NoteCard `Modifier.testTag().onPlacedHook()` added; production cost ≤ 1 SemanticsModifier allocation per card.
- [ ] **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._
- [x] **Phase 5.2**: 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. Two of the four originally-named regression tests landed via `AppStateMachineTest` (commit `48a8178c9`) — `bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects` covers the "no relays available" scenario, and `bootstrapSubscriptionFiresAtMostOncePerAccountLoad` covers the "no double-subscribe" scenario. The other two named cases (`slowRelay`, `relayListArrivesLate`) are now trivial to add on top of the same harness if a future regression motivates them, but were not necessary for the in-scope invariant. The DM gate at `Main.kt:1290` is left untouched in this session (separate code path through `subscriptionsCoordinator`).
### Non-Functional Requirements
- [ ] Warm benchmark variance ≤ 15% on `t_n_events` (median-to-median across 5 runs of the suite).
- [ ] `InProcessWebsocketBuilder` adds ≤ 50ms RTT vs direct in-process call.
- [ ] Cold harness total wall clock ≤ 4 min, warm ≤ 2 min, on a developer M1/M2/M3 laptop.
### Quality Gates
- [ ] All new tests pass `./gradlew :desktopApp:jvmTest` and `./gradlew :quartz:test`.
- [ ] Existing tests unaffected.
- [ ] `./gradlew spotlessApply` clean. `.spotless/copyright.kt` header on every new `.kt`.
- [ ] Pre-commit hook passes without `--no-verify`.
- [ ] Each Phase 5 PR includes before/after numbers table in description.
- [ ] No new `runBlocking { ... }` calls in launch path.
- [ ] No production code references `LaunchMarkers`.
---
## Success Metrics
Per brainstorm (Q4 + Q6) with deepen-plan adjustments:
| Metric | What | Reporting |
|--------|------|-----------|
| `t_first_composition_apply` | First `setContent` reaches idle (NOT real Skia frame) | ms, cold + warm |
| `t_account_logged_in` | `AccountState.LoggedIn` emitted | ms, cold + warm |
| `t_first_event` | First `NoteCard.onPlaced` fires | ms, cold + warm |
| `t_n_events` (N=10) | 10th `NoteCard.onPlaced` fires | ms, cold + warm — **headline** |
(Memory + warm-boot variant DEFERRED.)
Cold = shell-script, fork per sample, N=10, no warmup. Warm = single JVM, N=20, discard first 5. Report **median + IQR + min**. For fix deltas, **Mann-Whitney U** with Cliff's delta > 0.33 threshold (perf reviewer).
**Phase 5 success:** each fix produces positive delta on `t_n_events` (or for 5.1, on the microbench if end-to-end is below the `InProcessWebSocket` floor). Sum target ≥ 20% reduction in cold `t_n_events` — aspirational, actual depends on baseline.
---
## Dependencies & Risks
### Risks (prioritized)
**Risk #1 (mitigated) — `:quartz` + `java-test-fixtures` setup.** `:quartz` already consumes geode testFixtures (`quartz/build.gradle.kts:352-358`). Producing fixtures from `:quartz` requires the `java-test-fixtures` plugin on `:quartz` itself, which is a separate enablement. **Mitigation**: time-box to 1 hour; fall back to `:desktopApp:jvmTest` directly if Gradle friction blocks.
**Risk #2 (acknowledged) — `runComposeUiTest` / `createComposeRule` doesn't capture real Swing/Skia render time.** Headline metric `t_first_composition_apply` measures composition only, not paint. **Mitigation**: explicit "out-of-scope: GPU/paint timing" disclosure in benchmark output header. Real-window benchmark named as follow-up: `desktopApp/plans/<future>-real-window-launch-benchmark.md`.
**Risk #3 — Fixture staleness.** Commit as immutable; recapture explicit human action only; PR includes new numbers.
**Risk #4 — Phase 5.2 eager subscription causes double-subscribe on relay list reorder.** Test: `relaysAddedMidLoadDoNotDoubleSubscribe`.
**Risk #5 — `InProcessWebSocket` floor swallows fix delta.** Microbench for icon decode in 5.1; Mann-Whitney U test for significance.
**Risk #6 — Cold harness fork cost (~25s/sample × N=10 = ~4 min).** Acceptable for local-manual runs; document in README.
**Risk #7 — JVM/OS/arch variation across developers.** Output header records env; cross-machine numbers informational only.
### Dependencies
- `quartz`: `InProcessWebSocket`, `NostrServer`, `Filter`, `WebsocketBuilder` — all exist.
- `commons`: `EventStore`, `DesktopLocalCache` — exist.
- `desktopApp`: `AccountManager`, `LocalRelayStore`, `RelayConnectionManager`, `App()` — exist with needed seams post 1.3.
- Compose UI test: `compose.desktop.uiTestJUnit4` on classpath (`desktopApp/build.gradle.kts:87`).
- Gradle `java-test-fixtures` plugin (proven in `:geode`; needs enablement on `:quartz`).
---
## Resource Requirements
Solo engineer, rough sizing:
| Phase | Effort |
|-------|--------|
| 1.1 | ~half day |
| 1.2 | ~half day |
| 1.3 | ~hour |
| 1.4 | ~day |
| 2.1 | ~hour |
| 2.2 | ~half day |
| 2.3 | ~hour (manual fixture capture + README) |
| 2.4 | ~hour |
| 3.1 | ~half day |
| 3.2 | ~day (two harnesses + report.py) |
| 4 | ~hour |
| 5.1 | ~hour + microbench |
| 5.2 | ~day (investigation + refactor + 4 tests) |
---
## Future Considerations
- **Real-render benchmark** (`desktopApp/plans/<future>-real-window-launch-benchmark.md`) — `application { Window { App() } }` with file-marker IPC. Catches GPU regressions.
- **Phase 5.3 follow-up** (`desktopApp/plans/<future>-feat-maincontent-state-holder-refactor-plan.md`) — only if Phase 4 baseline shows `MainContent` cost > 50ms.
- **Android port** (brainstorm Q14) — separate brainstorm, `androidx.benchmark.macro.StartupTimingMetric`.
- **Memory metric** — add after baseline shows it's worth measuring; pin GC mode, parse JFR.
- **Larger fixture for stress (`fiatjaf-5000.jsonl`)** — catches O(n²) regressions; defer until needed.
- **CI integration** — track baseline on `main` for trend visibility (no PR gating).
- **Tor cold boot** — measure separately; out of scope (Tor splash gate is shutdown-shape, not boot-shape).
---
## Documentation Plan
- This plan — primary reference.
- `quartz/src/testFixtures/resources/fixtures/launch/README.md` — fixture recapture commands.
- `desktopApp/benchmarks/README.md` — how to run benchmarks, interpret numbers, known caveats.
- KDoc on `LaunchMarkers`, `InProcessWebsocketBuilder`, `FixtureNostrServer`, `NoteCardTags`.
- Plan update after Phase 4 with baselines.
- Plan update after Phase 5.1 and 5.2 with deltas.
---
## Sources & References
### Origin
- **Brainstorm:** [`docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md`](../../docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md). Key decisions carried forward: Approach A (foundation-first); Desktop-first / Android deferred; layered pyramid; in-process fake relay; fresh-boot fixture; N=10 events visible as headline; local-only manual runs; cold + warm reported separately; real-world fixture; 3 in-scope fixes (now 2 in-scope, 1 deferred).
### Internal References
- `desktopApp/src/jvmMain/.../Main.kt:218,302,988` — 3× ImageIO.read of /icon.png.
- `desktopApp/src/jvmMain/.../Main.kt:1283-1330` — home + DM subscriptions gated on first connected relay.
- `desktopApp/src/jvmMain/.../Main.kt:834` — production `WebsocketBuilder` injection site.
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:71-86` — `AccountState`.
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:240-266` — `loadSavedAccount`.
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:676-695` — `loadReadOnlyAccount`.
- `desktopApp/src/jvmMain/.../relay/LocalRelayStore.kt:38-103,115-161` — store ctor + hydrate.
- `desktopApp/src/jvmMain/.../network/RelayConnectionManager.kt:56-58` — `open class` taking `WebsocketBuilder`.
- `desktopApp/src/jvmMain/.../network/DesktopRelayConnectionManager.kt:30-34`.
- `quartz/src/commonMain/.../sockets/WebsocketBuilder.kt:25-30`.
- `quartz/src/commonMain/.../relay/server/inprocess/InProcessWebSocket.kt:55`.
- `quartz/src/commonMain/.../relay/client/NostrClient.kt:80-82`.
- `quartz/src/commonMain/.../relay/filters/Filter.kt:51-61`.
- `desktopApp/src/jvmTest/.../ui/DesktopLaunchSmokeTest.kt:24,57,58,69` — existing UI test pattern.
- `desktopApp/src/jvmTest/.../account/AccountManagerStateTransitionTest.kt:73-95` — existing state-transition test pattern.
- `desktopApp/src/jvmMain/.../ui/note/NoteCard.kt:97` — testTag/onPlaced injection site.
- `quartz/build.gradle.kts:352-358` — `:quartz` consuming `testFixtures(project(":geode"))`.
- `geode/build.gradle.kts:7,35-37,81-83` — `java-test-fixtures` precedent.
- `.spotless/copyright.kt` — required header for new files.
### Related Work
- `docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md` — adjacent viewport-aware metadata.
- `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md` — `LocalRelayStore` infrastructure this plan reuses.
### External References
- Compose UI test: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html
- kotlinx-coroutines test: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/
- Gradle `java-test-fixtures`: https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures