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

42 KiB
Raw Blame History

title, type, status, date, origin, deepened
title type status date origin deepened
App Launch Optimization (Desktop, Foundation-first) feat active 2026-06-17 docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md 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 deferredrunComposeUiTest 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_framet_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 seamInProcessWebsocketBuilder + 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

// 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_eventModifier.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:

// 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):

@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

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

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:

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 harnessdesktopApp/benchmarks/cold-launch.sh:

#!/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 harnessWarmLaunchBenchmark 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:

// 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: RelayConnectionManagerNostrClientRelayPool 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

  • 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).
  • Phase 1.2: 5 LocalRelayStoreHydrationTest cases pass. Landed 2026-06-17 (commit ff55898ab).
  • Phase 1.3: LocalRelayStore accepts optional homeDir; existing callers unchanged; LocalRelayMaintenance untouched. Landed 2026-06-17 (commit ff55898ab).
  • 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.
  • 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.
  • 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: 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. 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-86AccountState.
  • desktopApp/src/jvmMain/.../account/AccountManager.kt:240-266loadSavedAccount.
  • desktopApp/src/jvmMain/.../account/AccountManager.kt:676-695loadReadOnlyAccount.
  • desktopApp/src/jvmMain/.../relay/LocalRelayStore.kt:38-103,115-161 — store ctor + hydrate.
  • desktopApp/src/jvmMain/.../network/RelayConnectionManager.kt:56-58open 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-83java-test-fixtures precedent.
  • .spotless/copyright.kt — required header for new files.
  • docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md — adjacent viewport-aware metadata.
  • desktopApp/plans/2026-05-09-embedded-local-relay-plan.mdLocalRelayStore infrastructure this plan reuses.

External References