DESIGN.md@docs/daemon · git:20260905.7460d58 · 2026-09-05 · sha256 7619ea5455ae2934
DESIGN.md@docs/daemon git:20260905.7460d58A
Immutable. This exact content is served forever at /api/v1/blob/7619ea5455ae2934.
# Persistent preview server — design
## 1. Goals & non-goals
**Goals**
- Sub-second preview refresh for a single focused preview after a
no-classpath-change file save in VS Code.
- Eliminate Gradle configuration, JVM fork startup, and Robolectric
sandbox bootstrap from the per-save hot path.
- Keep the existing `composePreviewRender` Gradle task path untouched and
always available.
**Non-goals (v1)**
- Per-project (cross-module) sandbox sharing — each consumer module
gets its own daemon JVM.
- The `compose-preview` CLI binary keeps using the Gradle task. (MCP
daemon mode is shipped separately as `:mcp` — see [MCP.md](MCP.md).)
- Replacing `composePreviewRender` — daemon fronts it for the editor loop only.
- Hot kotlinc / compile-daemon integration. (Stage-1 spike behind
`composePreview.daemon.continuousCompile` — long-running `gradle
--continuous` worker per module — landed for measurement. Stage-2
in-daemon Kotlin Build Tools API integration shipped in `:daemon:core`'s
`bta/` package, gated behind `composePreview.daemon.compileInProcess`.)
- Tier-3 dependency-graph reachability index — v1 uses a conservative
"module-changed = all previews stale, filtered by visibility" rule.
## 4. Architecture
```
VS Code extension
│
│ JSON-RPC over stdio
▼
preview-daemon (one JVM per consumer module)
│
├── ManifestWatcher — debounced file events from VS Code
├── IncrementalDiscovery — re-scans only changed source class dirs
├── FocusTracker — current visible-preview set from VS Code
├── RenderQueue — coalesces, prioritises focused previews first
└── RenderHost — renderer-specific sandbox holder + warm spare
│
├── (android) RobolectricHost — Compose-test-rule + ComponentActivity
└── (desktop) DesktopHost — Recomposer + Skiko surface
```
**Per-module, not per-project.** Robolectric sandbox config is a
function of the consumer module's classpath + AndroidX versions +
`compileSdk`. One sandbox per module sidesteps version skew.
The daemon is **launched by VS Code** but **bootstrapped by Gradle** —
Gradle is invoked once at startup to compute the test classpath, JVM
args, and `robolectric.properties` (Android) or the Skiko classpath
(desktop). New Gradle task `composePreviewDaemonStart` emits a JSON
descriptor (classpath, JVM args, system props, java launcher path); VS
Code execs `java` with those args.
A manual `./gradlew composePreviewDaemonStart --foreground` mode is
also available for debugging without VS Code in the loop.
### Renderer-agnostic surface
The protocol, the JSON-RPC server, and everything in the VS Code
extension are **deliberately agnostic to which renderer is on the
other end of the wire**. None of the message shapes in
[PROTOCOL.md](PROTOCOL.md) mention Android, Robolectric, Skiko, or
Compose Desktop; they trade in `previewId`, `pngPath`, `metrics.tookMs`.
The only renderer-specific code is the Kotlin `RenderHost`
implementation inside the per-target daemon module.
| Module | Host | What it sandboxes |
|----------------------------|-----------------------|--------------------------------------------------------------------|
| `:daemon:android` | `RobolectricHost` | Robolectric `InstrumentingClassLoader`, `ComponentActivity` |
| `:daemon:desktop` | `DesktopHost` | Plain JVM classloader, `Recomposer`, Skiko `Surface` |
A `:daemon:core` shared module holds the protocol types
(`Messages.kt`), the JSON-RPC server (`JsonRpcServer.kt`), and the
abstract `RenderHost` interface; both per-target modules depend on it.
### Module layout
```
renderers/android/ UNCHANGED — RobolectricRenderTest.kt etc.
renderers/desktop/ UNCHANGED — existing Skiko renderer
daemon/core/ NEW — pure JVM, renderer-agnostic
src/main/kotlin/.../daemon/
JsonRpcServer.kt stdio JSON-RPC + Content-Length framing
rpc/RpcMethodRegistry.kt method → handler dispatch map + RpcPeer port
history/HistoryRpcHandlers.kt the `history/*` methods, beside HistoryManager
RenderHost.kt Abstract host interface
IncrementalDiscovery.kt Tier-2 scoped ClassGraph
DependencyIndex.kt Tier-3 ASM walk + reverse index (v2)
ClasspathFingerprint.kt Tier-1 dirty detection
SandboxScope.kt Per-classloader storage helper
ProcessCache.kt Process-level pure-data cache helper
SandboxLifecycle.kt Measurement + recycle policy + warm spare
protocol/
Messages.kt @Serializable request/response types
daemon/android/ NEW — depends on renderer-android + core
src/main/kotlin/.../daemon/
DaemonMain.kt Wires RobolectricHost + JsonRpcServer
RobolectricHost.kt Holds Robolectric sandbox open
SandboxHoldingRunner.kt Robolectric runner that exposes the bridge package
bridge/DaemonHostBridge.kt Cross-classloader handoff
RenderEngine.kt Per-preview render body
daemon/desktop/ NEW — depends on renderer-desktop + core
src/main/kotlin/.../daemon/
DaemonMain.kt Wires DesktopHost + JsonRpcServer
DesktopHost.kt Holds Recomposer + Skiko surface open
RenderEngine.kt Per-preview render body
gradle-plugin/ ADDITIVE ONLY (one helper extraction)
src/main/kotlin/.../plugin/daemon/
DaemonBootstrapTask.kt Emits launch-descriptor JSON
DaemonExtension.kt composePreview.daemon { … }
DaemonClasspathDescriptor.kt Serialises the JVM launch spec
compose-preview-vscode/ ADDITIVE ONLY (one router shim)
src/daemon/
daemonClient.ts JSON-RPC over stdio
daemonProcess.ts Spawn/respawn/health
daemonProtocol.ts Types mirroring Messages.kt
daemonGate.ts Feature-flag check + fallback to gradleService
samples/
android-daemon-bench/ Android latency harness
desktop-daemon-bench/ Desktop latency harness (D2-desktop)
```
## 5. Daemon lifecycle
### Bootstrap
The daemon runs a single dummy `@Test` whose body blocks on a
`LinkedBlockingQueue<RenderRequest>` until shutdown. This holds a
Robolectric sandbox open without re-implementing sandbox setup — we
inherit all the `robolectric.properties` plumbing for free. The "test"
never returns; the JVM exits when the daemon stops.
**In-JVM sandbox pool (SANDBOX-POOL.md).** With
`composeai.daemon.sandboxCount > 1` the daemon launches that many
worker threads, each running an independent JUnit invocation against
the same `SandboxRunner`. Concurrent `renderNow` requests dispatch
across slots via `Math.floorMod(id, sandboxCount)`. Default 4 sandboxes
per daemon.
### Per-preview render loop
Prologue:
1. Drain ShadowPackageManager records added by the previous preview.
2. Reset `RuntimeEnvironment.setQualifiers/setFontScale` for the new
preview.
3. Re-create `ComponentActivity`.
Render body: same as `RobolectricRenderTest` — `setContent { ... }`,
`mainClock.autoAdvance = false`, `advanceTimeBy(CAPTURE_ADVANCE_MS)`,
`captureRoboImage(...)`.
Epilogue:
1. `setContent { }` (empty) to give Compose a frame to dispose
`LaunchedEffect` / `DisposableEffect`.
2. Encode bitmap, then `bitmap.recycle()`.
3. Close any `HardwareRenderer` / `ImageReader` opened by the capture
path.
### Shutdown
`JsonRpcServer.shutdown` (PROTOCOL.md § 3) drains the in-flight queue
before resolving the response. JVM SIGTERM handler waits for the drain
before exit.
## 8. Staleness cascade — when do we re-render
A four-tier cascade. Each tier is cheaper than the next; stop at the
cheapest "no work" answer.
> **Implementation note:** Tier 2's "preview source changed" trigger is
> necessary but not sufficient on its own. Once discovery has identified
> a stale preview, the daemon still needs to load fresh bytecode for
> that preview class. The parent/child classloader split in
> [CLASSLOADER.md](CLASSLOADER.md) is the source of truth for that save
> loop.
### Tier 1 — project fundamentally changed
**Trigger:** classpath JAR list, Compose/AndroidX versions,
`compileSdk`, Robolectric config, or `robolectric.properties` content
changed.
**Cheap signal:** SHA-256 over a small fixed set: `libs.versions.toml`,
all `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`,
`local.properties`. Recompute only on file save in those paths.
**Authoritative signal:** SHA over the resolved test runtime classpath
JAR list (paths + mtimes), computed at daemon start and re-checked on
cheap-signal hit.
**Action:** emit `classpathDirty`, exit cleanly. VS Code re-runs
`composePreviewDaemonStart` and the new daemon comes up with the new
classpath. Do **not** swap classloaders in-place.
### Tier 2 — preview list possibly changed
**Trigger:** edit to a `.kt` file that either currently contributes
previews or might newly contribute one.
**"Currently contributes" set is free** once `sourceFile: String?` is
on `PreviewInfo`. Save to a file in this set → re-run discovery scoped
to just that file's compiled classes.
**"Might newly contribute" via cheap pre-filter:** regex-grep the saved
file's text for `@Preview`. Match → escalate to discovery. No match →
Tier 2 clean. ~1ms per save.
**Incremental discovery scope:** ClassGraph filtered to a single
classpath element / package. After kotlinc rebuilds, re-scan only
`build/.../classes/` paths whose mtime moved. Diff against cached
`previews.json`, emit `discoveryUpdated`.
### Tier 3 — a preview's render output may have changed
**v1 conservative:** any `.kt` change inside the module's source set
marks **every preview in the module** as stale. Combined with Tier 4
(focus filter), the waste is bounded.
**v2 precise (deferred):** per-preview reachable-class set built via
ASM walk at discovery time. Reverse index `class → previews that
transitively reference it`.
**Resources:** treat any `res/**` change as "all previews in module
stale" for v1.
### Tier 4 — is the user looking at this?
> See also [PREDICTIVE.md](PREDICTIVE.md) for the speculative-prefetch
> tiers on top of the reactive `setVisible` / `setFocus` signals.
**State from VS Code:**
- `setVisible({ ids })` — preview cards currently visible.
- `setFocus({ ids })` — active selection. Rendered first.
**Render policy:**
- Stale ∩ visible → render now, in priority order (focus first).
- Stale ∩ not-visible → mark stale, render lazily on scroll-into-view.
- Not stale → no-op.
**Coalescing:** rapid saves produce overlapping stale sets. The render
queue dedupes by preview ID. If a render is in-flight when its preview
is re-marked stale, mark "needs another pass after this one finishes"
rather than cancelling — Robolectric mid-render cancellation is a leak
source.
## 9. No mid-render cancellation — invariant + enforcement
Once a render has started, it runs to completion. This is load-bearing
for memory safety: aborting between any prologue / body / epilogue step
leaves the sandbox holding a half-disposed Compose graph, an
unrecycled `Bitmap` whose native `GraphicBuffer` is still owned by the
`HardwareRenderer`, or `ShadowPackageManager` / `ActivityScenario`
state the next preview will trip over. The worst failure shape is
silent visual drift — colour-bleed across previews when a buffer is
reused.
Enforced in code:
- Render thread does **not** poll `Thread.interrupted()`; the daemon's
own code never calls `interrupt()` on it.
- Shutdown is a poison-pill on `DaemonHostBridge`'s request queue, not a thread
abort. The in-flight render finishes before the sandbox tears down.
- `JsonRpcServer.shutdown` (PROTOCOL.md § 3) drains the in-flight queue
before resolving the response.
- JVM SIGTERM handler waits for the drain before exit.
- A regression test submits a render, immediately invokes shutdown, and
asserts the render still completes and the result is observable.
- End-to-end coverage: scenario S2 in
[TEST-HARNESS.md § 3](TEST-HARNESS.md#3-scenarios-catalogue).
## 10. Memory leak defense
Three layers, plus warm-spare to hide cost, plus proactive fixes for
known leak shapes.
**Layer 1 — measure on every render (always on).** Cheap (<5ms),
emitted on `renderFinished`: heap after GC, native/off-heap, class
instance counts (Composition, Recomposer, ComposeView,
ComponentActivity, Bitmap, HardwareRenderer, ImageReader), render time,
sandbox age.
**Layer 2 — active leak detection (periodic, opt-in).** Every Nth
render or via `--detect-leaks`: weak-reference probe, LeakCanary JVM
on-demand, JFR ring buffer.
**Layer 3 — recycle.** Triggers: heap > `daemon.maxHeapMb`, heap drift,
render time drift, render count > `daemon.maxRendersPerSandbox`,
`leakSuspected`. Each trigger emits `sandboxRecycle({ reason, ageMs,
renderCount })`.
**Layer 4 — known leak shapes, fixed proactively.** Empty `setContent
{ }` flush before teardown, Activity recreate per preview, Bitmap
recycle, HardwareRenderer/ImageReader closed in `finally`,
ShadowPackageManager adds tracked and reversed.
**Warm spare.** Daemon keeps two sandbox slots: `active` and `spare`.
Background thread builds a new `spare` after every recycle. Recycle =
atomically swap `spare → active`, schedule old `active` for teardown.
**Sandbox teardown verification.** Drop the strong sandbox reference,
force GC, check the WeakReference. If it doesn't clear within 2 GCs →
log `sandboxLeaked`. After 3 events, exit cleanly.
## 13. Latency budget
**Daemon-warm floor for a single focused preview:** kotlinc (1–2s) + 1
render (0.3–1s) ≈ 1.5–3s. Sub-second is achievable when no kotlinc
work is needed. v1 target: **< 1s for a single focused preview when no
kotlinc work is needed; < 3s with kotlinc.** Measured baseline captured
by P0.1 — see [`baseline-latency.csv`](baseline-latency.csv) +
[methodology sidecar](baseline-latency.md).
## 17. Module split (renderer-agnostic surface)
The seam at `:daemon:core` lets desktop and Android share everything
except the `RenderHost` implementation. `JsonRpcServer.kt` and
`Messages.kt` live in core; `RobolectricHost.kt` stays in
`:daemon:android`; `DesktopHost.kt` stays in `:daemon:desktop`. Both
backends evolve in parallel against a single protocol surface.
**Why desktop first for new features.** Desktop is the simpler
implementation surface — no Robolectric `InstrumentingClassLoader`, no
`bridge` package classloader workaround, no `HardwareRenderer`/`Bitmap`
native-buffer leak shapes, sub-second cold init. UX-facing features
(predictive prefetch, the cost model in [PREDICTIVE.md
§ 6a](PREDICTIVE.md#6a-ux-response--predicted-vs-measured-cost-model),
`MetricsSink` observability, the multi-tier render queue) get a shorter
feedback loop on desktop. Once a feature is proven on desktop, the
Android backend picks it up via the shared `:daemon:core` module
without code duplication.
**Roborazzi as `compileOnly`.** When the daemon ships as a Maven
artifact, Roborazzi stays runtime-supplied — the consumer's existing
Compose + Roborazzi pair is what gets loaded. Same pattern the daemon
already uses for `compose-ui-test-junit4`, `activity-compose`, etc.
Public Roborazzi surface used by `RenderEngine` is small
(`captureRoboImage`, `RoborazziOptions`,
`RoborazziOptions.RecordOptions(applyDeviceCrop = …)`). Fallback if
Roborazzi's API ever breaks: § 19.
## 19. captureToImage fallback path
If a future Roborazzi release breaks public-API binary stability — by
removing `RoborazziOptions.RecordOptions(applyDeviceCrop)`, changing
`captureRoboImage`'s signature, or moving classes between packages —
the daemon has a documented migration path that does not require
shipping multiple JARs.
**Replacement: `androidx.compose.ui.test.captureToImage()`.** This is
the upstream Compose UI Test API. It walks the same `HardwareRenderer`
path Roborazzi does under `@GraphicsMode(NATIVE)` (already required by
`RobolectricHost.SandboxRunner`), so the Robolectric prerequisite is
unchanged. The replacement is local to `RenderEngine.render()`:
```kotlin
// Before (Roborazzi):
val opts = RoborazziOptions(recordOptions = RoborazziOptions.RecordOptions(applyDeviceCrop = isRound))
rule.onRoot().captureRoboImage(file = outputFile, roborazziOptions = opts)
// After (captureToImage):
val captured = rule.onRoot().captureToImage().asAndroidBitmap()
val final = if (isRound) applyCircularCrop(captured) else captured
FileOutputStream(outputFile).use { final.compress(Bitmap.CompressFormat.PNG, 100, it) }
```
`applyCircularCrop` is a ~20-line Bitmap+Canvas+BitmapShader helper
replacing Roborazzi's `applyDeviceCrop`.
**What this fallback does NOT cover.** Roborazzi's richer features used
by `:renderer-android`'s `RobolectricRenderTest` —
`RoborazziComposeOptions` builders, animated/scroll/GIF stitching,
accessibility tree extraction, `roborazzi-accessibility-check` — are
out of scope for the daemon. If `:renderer-android` needs to migrate
too, that's a much larger project; this fallback only addresses the
daemon's Roborazzi surface.