mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(desktop): launch-optimization plan + test pyramid foundation
Phase 1 of the desktop launch-optimization plan: pin the behavior of the cold-boot critical path before any launch refactor lands. * Plan document committed to desktopApp/plans/. * AccountManager: ViewOnly load + decode-failure state transitions are pinned by AccountManagerLoadStateTransitionsTest (2 tests). * LocalRelayStore: gains a homeDir constructor parameter so tests can point the SQLite event store at a temp directory; production callers unchanged via default argument. * LocalRelayStoreHydrationTest pins hydrate's contract: - empty DB is a no-op, - kind:3 contact list is consumed before kind:0 metadata, - kind:1 within the 7-day window is hydrated, - kind:1 older than 7 days is excluded. All 266 desktopApp tests pass. No production behavior change.
This commit is contained in:
@@ -0,0 +1,653 @@
|
||||
---
|
||||
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)
|
||||
|
||||
## 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
|
||||
|
||||
- [ ] **Phase 1.1**: 2 tests (ViewOnly happy + decode failure) pass using repo's existing pattern (`backgroundScope.launch(UnconfinedTestDispatcher(testScheduler))` + `advanceUntilIdle()`).
|
||||
- [ ] **Phase 1.2**: 5 `LocalRelayStoreHydrationTest` cases pass.
|
||||
- [ ] **Phase 1.3**: `LocalRelayStore` accepts optional `homeDir`; existing callers unchanged; `LocalRelayMaintenance` untouched.
|
||||
- [ ] **Phase 1.4**: 3 `AppStateMachineTest` Compose UI tests pass via `createComposeRule()`, ≤ 5s each, with `MaterialTheme {}` wrap.
|
||||
- [ ] **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**: 1.4 tests still pass using fixture-driven relay.
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
|
||||
### 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
|
||||
+5
-4
@@ -37,15 +37,16 @@ import java.io.File
|
||||
|
||||
class LocalRelayStore(
|
||||
private val scope: CoroutineScope,
|
||||
private val homeDir: File = File(System.getProperty("user.home")),
|
||||
) : AutoCloseable {
|
||||
companion object {
|
||||
val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
|
||||
|
||||
private fun dbDir(pubKeyHex: String): File = File(System.getProperty("user.home"), ".amethyst/accounts/${pubKeyHex.take(8)}")
|
||||
|
||||
fun dbFile(pubKeyHex: String): File = File(dbDir(pubKeyHex), "events.db")
|
||||
}
|
||||
|
||||
private fun dbDir(pubKeyHex: String): File = File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
|
||||
|
||||
fun dbFile(pubKeyHex: String): File = File(dbDir(pubKeyHex), "events.db")
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.account
|
||||
|
||||
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.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Phase 1.1 of the launch-optimization plan: pin the ViewOnly cold-boot
|
||||
* state transition behavior the benchmark scenario depends on.
|
||||
*
|
||||
* Only the ViewOnly path is covered here. Internal + Remote (bunker) are
|
||||
* intentionally out of scope — they are exercised by the existing tests in
|
||||
* [AccountManagerStateTransitionTest] / [AccountManagerKeyLoginTest] /
|
||||
* [AccountManagerBunkerLoginTest].
|
||||
*
|
||||
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 1.1.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AccountManagerLoadStateTransitionsTest {
|
||||
private lateinit var storage: SecureKeyStorage
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var manager: AccountManager
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
|
||||
tempDir = createTempDirectory("acctmgr-load-state").toFile()
|
||||
File(tempDir, ".amethyst").mkdirs()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun viewOnlyAccountTransitionsThroughLoadingToLoggedIn() =
|
||||
runTest {
|
||||
val states = mutableListOf<AccountState>()
|
||||
val collector =
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
manager.accountState.toList(states)
|
||||
}
|
||||
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
manager.accountStorage.saveAccount(
|
||||
AccountInfo(npub = npub, signerType = SignerType.ViewOnly),
|
||||
)
|
||||
manager.accountStorage.setCurrentAccount(npub)
|
||||
|
||||
val result = manager.loadSavedAccount()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(result.isSuccess, "loadSavedAccount should succeed for ViewOnly: $result")
|
||||
assertTrue(
|
||||
states.size >= 2,
|
||||
"Expected at least 2 state transitions, got ${states.size}: $states",
|
||||
)
|
||||
assertIs<AccountState.Loading>(states.first())
|
||||
val terminal = assertIs<AccountState.LoggedIn>(states.last())
|
||||
assertEquals(true, terminal.isReadOnly, "ViewOnly account must be flagged read-only")
|
||||
assertEquals(SignerType.ViewOnly, terminal.signerType)
|
||||
assertEquals(null, terminal.nsec, "ViewOnly account must not expose nsec")
|
||||
assertEquals(npub, terminal.npub)
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptViewOnlyAccountFailsWithoutEmittingLoggedIn() =
|
||||
runTest {
|
||||
val states = mutableListOf<AccountState>()
|
||||
val collector =
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
manager.accountState.toList(states)
|
||||
}
|
||||
|
||||
// Persist a ViewOnly account with an undecodable npub. accounts.json.enc
|
||||
// round-trips the string as-is, so loadSavedAccount will hit the
|
||||
// decodePublicKeyAsHexOrNull failure branch in loadReadOnlyAccount.
|
||||
manager.accountStorage.saveAccount(
|
||||
AccountInfo(npub = "npub1notavalidbech32string", signerType = SignerType.ViewOnly),
|
||||
)
|
||||
manager.accountStorage.setCurrentAccount("npub1notavalidbech32string")
|
||||
|
||||
val result = manager.loadSavedAccount()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(result.isFailure, "Corrupt ViewOnly npub must fail load")
|
||||
assertTrue(
|
||||
states.none { it is AccountState.LoggedIn },
|
||||
"No LoggedIn state must be emitted for corrupt account: $states",
|
||||
)
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* 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.relay
|
||||
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
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.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Phase 1.2 of the launch-optimization plan: pin the invariants of
|
||||
* [LocalRelayStore.hydrate] before any launch-path refactor touches it.
|
||||
*
|
||||
* The hydrate phases (per LocalRelayStore.kt:115-161) are:
|
||||
* 1. kind:3 contact list (populates [DesktopLocalCache.followedUsers]).
|
||||
* 2. kind:0 metadata for followed users (depends on #1 having run).
|
||||
* 3. recent activity events (kinds 1/6/7/16/1111/9735) since now-7d.
|
||||
*
|
||||
* Tests seed a fresh SQLite DB at the location LocalRelayStore will open,
|
||||
* then exercise hydrate against a real [DesktopLocalCache] and assert on
|
||||
* the observable side effects (cache users, notes, followedUsers).
|
||||
*
|
||||
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 1.2.
|
||||
*/
|
||||
class LocalRelayStoreHydrationTest {
|
||||
private lateinit var tempHome: File
|
||||
private lateinit var ownerKeyPair: KeyPair
|
||||
private lateinit var ownerPubKey: String
|
||||
private lateinit var dbPath: String
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
tempHome = createTempDirectory("localrelay-hydrate").toFile()
|
||||
ownerKeyPair = KeyPair()
|
||||
ownerPubKey = ownerKeyPair.pubKey.toHexKey()
|
||||
val dbDir = File(tempHome, ".amethyst/accounts/${ownerPubKey.take(8)}").also { it.mkdirs() }
|
||||
dbPath = File(dbDir, "events.db").absolutePath
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
tempHome.deleteRecursively()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the SQLite file LocalRelayStore will open, then closes it
|
||||
* so the production code can re-open the same DB file.
|
||||
*/
|
||||
private suspend fun seedDatabase(events: List<Event>) {
|
||||
val seeder = EventStore(dbName = dbPath, relay = LocalRelayStore.LOCAL_RELAY_URL)
|
||||
try {
|
||||
seeder.batchInsert(events)
|
||||
} finally {
|
||||
seeder.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newStore(): LocalRelayStore = LocalRelayStore(scope = TestScope(), homeDir = tempHome).also { it.openForAccount(ownerPubKey) }
|
||||
|
||||
private fun makeContactList(
|
||||
owner: KeyPair,
|
||||
follows: List<String>,
|
||||
createdAt: Long = nowSeconds(),
|
||||
): ContactListEvent {
|
||||
val signer = NostrSignerSync(owner)
|
||||
val tags = follows.map { arrayOf("p", it) }.toTypedArray()
|
||||
return signer.sign<ContactListEvent>(
|
||||
createdAt = createdAt,
|
||||
kind = ContactListEvent.KIND,
|
||||
tags = tags,
|
||||
content = "",
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeMetadata(
|
||||
author: KeyPair,
|
||||
name: String,
|
||||
createdAt: Long = nowSeconds(),
|
||||
): MetadataEvent {
|
||||
val signer = NostrSignerSync(author)
|
||||
return signer.sign<MetadataEvent>(
|
||||
createdAt = createdAt,
|
||||
kind = MetadataEvent.KIND,
|
||||
tags = emptyArray(),
|
||||
content = """{"name":"$name"}""",
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeTextNote(
|
||||
author: KeyPair,
|
||||
content: String,
|
||||
createdAt: Long = nowSeconds(),
|
||||
): TextNoteEvent {
|
||||
val signer = NostrSignerSync(author)
|
||||
return signer.sign<TextNoteEvent>(
|
||||
createdAt = createdAt,
|
||||
kind = TextNoteEvent.KIND,
|
||||
tags = emptyArray(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private fun nowSeconds(): Long = System.currentTimeMillis() / 1000
|
||||
|
||||
@Test
|
||||
fun hydratingAnEmptyDatabaseSucceedsAndLeavesCacheEmpty() =
|
||||
runTest {
|
||||
val cache = DesktopLocalCache()
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
|
||||
assertTrue(cache.followedUsers.value.isEmpty(), "Empty DB must leave followedUsers empty")
|
||||
assertNull(store.lastError.value, "Empty hydrate must not flag a lastError")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kind3IsHydratedBeforeKind0SoMetadataLoadsForFollowedAuthors() =
|
||||
runTest {
|
||||
val followee = KeyPair()
|
||||
val contactList = makeContactList(ownerKeyPair, follows = listOf(followee.pubKey.toHexKey()))
|
||||
val followeeMetadata = makeMetadata(followee, name = "Followee")
|
||||
|
||||
// Insert metadata first, contact list second — the on-disk row order
|
||||
// is unrelated to the order hydrate() actually queries them. If
|
||||
// hydrate ran phases in the wrong order, followedUsers would be
|
||||
// empty when phase 2 ran and the metadata would never load.
|
||||
seedDatabase(listOf(followeeMetadata, contactList))
|
||||
|
||||
val cache = DesktopLocalCache()
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
followee.pubKey.toHexKey() in cache.followedUsers.value,
|
||||
"Phase 1 (kind:3) must populate followedUsers before phase 2 runs",
|
||||
)
|
||||
val followeeUser = cache.getUserIfExists(followee.pubKey.toHexKey())
|
||||
assertNotNull(followeeUser, "Followed user must be in cache after kind:3 phase")
|
||||
assertEquals(
|
||||
expected = "Followee",
|
||||
actual = followeeUser.toBestDisplayName(),
|
||||
message = "Phase 2 (kind:0) must run after phase 1 so metadata is applied to followed users",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentTextNotesWithinSevenDayWindowAreHydrated() =
|
||||
runTest {
|
||||
val author = KeyPair()
|
||||
val recentNote = makeTextNote(author, "recent", createdAt = nowSeconds() - 3600)
|
||||
seedDatabase(listOf(recentNote))
|
||||
|
||||
val cache = DesktopLocalCache()
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
|
||||
val note = cache.getNoteIfExists(recentNote.id)
|
||||
assertNotNull(note, "Recent text note must be hydrated into cache")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textNotesOlderThanSevenDaysAreSkipped() =
|
||||
runTest {
|
||||
val author = KeyPair()
|
||||
val eightDaysAgo = nowSeconds() - (8L * 24 * 3600)
|
||||
val oldNote = makeTextNote(author, "stale", createdAt = eightDaysAgo)
|
||||
seedDatabase(listOf(oldNote))
|
||||
|
||||
val cache = DesktopLocalCache()
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
|
||||
assertNull(
|
||||
cache.getNoteIfExists(oldNote.id),
|
||||
"Notes older than the 7-day hydration window must be excluded",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hydrateDoesNotEmitWasVerifiedFalseForLocalEvents() =
|
||||
runTest {
|
||||
// The wasVerified=true semantics of cache.consume during hydrate is
|
||||
// the contract we depend on: a tampered event that round-tripped
|
||||
// through this DB would be admitted without re-checking the
|
||||
// signature on every cold boot. The store's read-time gate is
|
||||
// SQLite write-time verification — exercised by EventStore tests —
|
||||
// and the hydrate path trusts it.
|
||||
//
|
||||
// Here we just verify the round-trip doesn't accidentally degrade
|
||||
// an event by re-checking signatures it wouldn't pass elsewhere.
|
||||
val author = KeyPair()
|
||||
val note = makeTextNote(author, "round-trip")
|
||||
seedDatabase(listOf(note))
|
||||
|
||||
val cache = DesktopLocalCache()
|
||||
val store = newStore()
|
||||
try {
|
||||
store.hydrate(cache)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
|
||||
assertFalse(
|
||||
cache.getNoteIfExists(note.id) == null,
|
||||
"Round-trip through hydrate must not drop a valid note",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user