diff --git a/.claude/plans/startup-time-analysis.md b/.claude/plans/startup-time-analysis.md new file mode 100644 index 0000000..58a2d52 --- /dev/null +++ b/.claude/plans/startup-time-analysis.md @@ -0,0 +1,74 @@ +# Startup time analysis — desktop launch (`--ui`) + +Measured on 2026-09-30, macOS arm64, Zulu 26.0.1 FX, `java -jar target/pholio.jar --ui` on a real library. +Class-load milestones from `-Xlog:class+load:file=cl.log:uptime` (JVM uptime), application milestones from +the log. + +## Timeline + +| JVM uptime | Step | Classes loaded | +|---:|---|---:| +| 0 → 0.077 s | JVM start, Spring Boot `JarLauncher` (nested jars) | 1 368 | +| **0.077 → 0.498 s** | **`FxThreadAgent.install()`: Byte Buddy self-attach + agent setup** | **1 696** | +| 0.498 → 0.563 s | picocli: `LaunchOptions` resolves UI vs headless | 321 | +| 0.563 → ~0.85 s | JavaFX toolkit (Glass, Prism/Metal, fonts at 0.69 s), splash built and first painted (`ProgressBarSkin` 0.83 s) | ~2 180 | +| ~0.85 s | **splash on screen** | | +| 1.098 s | `init()`: Spring `SpringApplication` starts | | +| ~1.7 → 2.2 s | settings/geo databases, then the library database opened + Flyway | | +| 2.5 → 3.8 s | components: media formats, recognition (ONNX models), UI views | | +| 3.8 s | Spring context ready | | +| 3.9 → 4.2 s | `start()`: main window built and shown, splash fades out | | + +Total ~4.2 s to the main window; ~11 950 classes load after the splash appears. + +## Before the splash (~0.85 s) + +1. **Byte Buddy agent, ~420 ms — half of it.** `main()` installs it first so `@FxThread` classes are woven + as they load. Options: + - **Install it in parallel**: start it on a thread first thing in `main()`, join it just before Spring + loads application classes (start of `PholioFxApplication.init()`, and before the headless commands run). + picocli, `GuiBootstrap`, JavaFX and the splash never use `@FxThread`. Splash would show at ~0.45 s. + To check: `PholioFxApplication` is loaded by JavaFX at 0.571 s, before the agent would be ready — it + must carry no `@FxThread` method. + - **Weave at build time** instead of at load time: removes the cost entirely (see the build-time weaving + discussion — the reason it was dropped was IDE incremental compilation overwriting woven classes). +2. **picocli, ~65 ms**: a fast path when the arguments are exactly `--ui` could skip the parse. Minor. +3. **JavaFX toolkit, ~290 ms**: mostly fixed. Only the splash image (1376×768 PNG) could be lighter + (JPEG): tens of milliseconds at most. + +Nothing Spring-related runs before the splash, so there is no bean to defer at that stage. + +### Done: build-time weaving, agent as a safety net (2026-09-30) + +`FxThreadPlugin` (byte-buddy-maven-plugin, `process-classes`) weaves `@FxThread` at build time and marks each +woven class `@FxThreadWoven`; `FxThreadAgent.install()` now only attaches when the application's classes run +from a directory (an IDE run), and then weaves only the unmarked classes. Measured on the Maven-built jar: +Byte Buddy is no longer loaded, `GuiBootstrap` is reached at 0.197 s (was 0.563 s) and the splash's first +paint at ~0.47 s (was ~0.83 s). A run from `target/classes` still installs the agent (logged at INFO). + +## AOT + +- **JDK AOT cache** (Leyden, `-XX:AOTCache`, JDK 24+): pre-loads and pre-links classes, which is exactly the + pre-splash cost (JavaFX, picocli, Byte Buddy). Caveats, to verify in practice: + - needs a plain-jar class path, not the fat jar's nested jars — the extracted layout + (`java -Djarmode=tools -jar … extract`) the installers already use fits; + - a load-time transforming agent like `FxThreadAgent` can prevent cached classes from being used for the + classes it transforms; + - classes Byte Buddy generates at runtime are not cached. + So removing or reducing the agent cost stays worthwhile even with the AOT cache. +- **Spring AOT** (`spring-boot:process-aot`): speeds up the context build, which happens *after* the splash — + shortens how long the splash stays, not when it appears. + +## After the splash (~3 s of Spring) — candidates to defer + +- ~1 s with no log between the media formats (2.5 s) and context ready (3.8 s): the ONNX recognition models + are loaded eagerly at startup; loading them on first use would move that off the critical path. +- `jSystemThemeDetector` / OSHI logs a warning during startup (`MacOperatingSystem: Unable …`). +- Confirm with Spring's `BufferingApplicationStartup`, which times every bean. + +## How to re-measure + +```bash +java -Xlog:class+load:file=cl.log:uptime --enable-native-access=ALL-UNNAMED -jar target/pholio.jar --ui +grep -m1 "org.icroco.pholio.ui.GuiBootstrap " cl.log # and other milestones +``` diff --git a/pom.xml b/pom.xml index a53a194..519dbee 100644 --- a/pom.xml +++ b/pom.xml @@ -26,11 +26,10 @@ 2.1.0 1.18.12 1.22.1 @@ -668,6 +667,32 @@ + + + net.bytebuddy + byte-buddy-maven-plugin + ${byte-buddy.version} + + + + org.icroco.pholio.ui.common.FxThreadPlugin + + + + + + + transform + + + + org.apache.maven.plugins maven-surefire-plugin diff --git a/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java b/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java index b1e2e62..3fb6e65 100644 --- a/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java +++ b/src/main/java/org/icroco/pholio/ui/PholioFxApplication.java @@ -14,7 +14,7 @@ import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import javax.imageio.ImageIO; -import java.awt.Taskbar; +import java.awt.*; import java.io.IOException; import java.io.InputStream; import java.util.Objects; @@ -39,7 +39,9 @@ public class PholioFxApplication extends Application { private @Nullable ConfigurableApplicationContext context; - /** Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}. */ + /** + * Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}. + */ private final StartupProgressReporter startupProgress = new StartupProgressReporter(this::notifyPreloader); @Override @@ -112,7 +114,8 @@ public class PholioFxApplication extends Application { try (InputStream in = PholioFxApplication.class.getResourceAsStream("/images/spo-macos-1024x1024.png")) { taskbar.setIconImage(ImageIO.read(Objects.requireNonNull(in, "spo-macos-1024x1024.png missing"))); log.debug("Dock icon set"); - } catch (IOException | RuntimeException e) { + } + catch (IOException | RuntimeException e) { log.warn("Could not set dock icon", e); } } diff --git a/src/main/java/org/icroco/pholio/ui/common/FxThread.java b/src/main/java/org/icroco/pholio/ui/common/FxThread.java index 8841c10..9c3d0a2 100644 --- a/src/main/java/org/icroco/pholio/ui/common/FxThread.java +++ b/src/main/java/org/icroco/pholio/ui/common/FxThread.java @@ -10,9 +10,10 @@ import java.lang.annotation.Target; * Marks a method — or, on a class, every method it declares — that must always run on the JavaFX * Application Thread, replacing a hand-written {@link FxUtils#onFxThread} wrapper around the body. * - *

Enforced by {@link FxThreadAgent}, a Byte Buddy agent that self-attaches at JVM startup and weaves - * every matching method as it loads — not a runtime check, not a Spring proxy, and not a build-time Maven - * plugin. None of those were arbitrary rejections: + *

Enforced by Byte Buddy bytecode weaving — at build time by {@link FxThreadPlugin} + * ({@code byte-buddy-maven-plugin}), and at load time by {@link FxThreadAgent} only for classes an IDE may + * have recompiled since — not a runtime check and not a Spring proxy. How it got there was no arbitrary + * choice: * *

* - *

A JVM agent has none of these limitations: it rewrites the method itself as the class loads, so - * self-invocation, constructor-time calls, and classes that are not Spring beans are all covered the same - * way — and unlike a build-time weave, it applies identically no matter which compiler produced the - * {@code .class} file being loaded. + *

Both weaves rewrite the method itself, so self-invocation, constructor-time calls, and classes that are + * not Spring beans are covered the same way. Combined — build-time weaving marking each class + * {@link FxThreadWoven}, the agent installed only for classes run from a directory and weaving only the + * unmarked ones — a Maven-built jar starts with no agent, and an IDE run is still always correct. * *

{@code void}-only. Off the FX thread, the wrapped call is dispatched via * {@code Platform.runLater} — fire-and-forget, with no return value a synchronous caller could receive. diff --git a/src/main/java/org/icroco/pholio/ui/common/FxThreadAgent.java b/src/main/java/org/icroco/pholio/ui/common/FxThreadAgent.java index 9574d7e..bf612bb 100644 --- a/src/main/java/org/icroco/pholio/ui/common/FxThreadAgent.java +++ b/src/main/java/org/icroco/pholio/ui/common/FxThreadAgent.java @@ -9,6 +9,10 @@ import net.bytebuddy.matcher.ElementMatcher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; + import static net.bytebuddy.matcher.ElementMatchers.isAbstract; import static net.bytebuddy.matcher.ElementMatchers.isAnnotatedWith; import static net.bytebuddy.matcher.ElementMatchers.isConstructor; @@ -23,16 +27,21 @@ import static net.bytebuddy.matcher.ElementMatchers.returns; * any class of this application's own is touched — since a class can only be woven as it loads, not * afterwards. * - *

Why a runtime agent, not a build-time Maven plugin. An earlier version of this - * weaved at build time via {@code byte-buddy-maven-plugin}, bound to Maven's {@code process-classes} - * phase. That worked for every build and test run driven through Maven — and broke silently the moment - * the application was actually launched from an IDE, because an IDE's own incremental compiler recompiles - * straight into {@code target/classes} on every run by default, overwriting the woven {@code .class} - * files with plain ones from a compiler that has never heard of this Maven plugin. The failure mode is not - * a build error anywhere — it is a scenegraph mutation running on the wrong thread at runtime, minutes - * later, with nothing in the stack trace to suggest {@code @FxThread} was ever involved. A JVM agent - * instruments classes as the JVM loads them, which happens identically no matter which compiler produced - * the {@code .class} file — IDE, {@code mvn spring-boot:run}, or the packaged jar. + *

Build-time weaving first, this agent only as the safety net. {@link FxThreadPlugin} + * weaves at build time ({@code byte-buddy-maven-plugin}, {@code process-classes}) and marks every class it + * weaves {@link FxThreadWoven}. Build-time weaving alone was tried first and dropped: an IDE's incremental + * compiler recompiles straight into {@code target/classes}, overwriting woven {@code .class} files with + * plain ones — no build error anywhere, just a scenegraph mutation on the wrong thread minutes later. And an + * agent alone costs ~420 ms before the splash screen can show (measured: Byte Buddy's self-attach and its + * ~1,700 classes), on every launch, packaged ones included. So {@link #install()} now decides: + *

*/ public final class FxThreadAgent { @@ -49,6 +58,13 @@ public final class FxThreadAgent { } installed = true; + String mode = System.getProperty("pholio.fxthread.agent", "auto"); + boolean fromDirectory = loadedFromDirectory(); + if ("never".equalsIgnoreCase(mode) || (!"always".equalsIgnoreCase(mode) && !fromDirectory)) { + log.debug("@FxThread woven at build time; no agent"); + return; + } + ByteBuddyAgent.install(); new AgentBuilder.Default() // AgentBuilder's default InitializationStrategy (SelfInjection) registers every visited @@ -67,11 +83,31 @@ public final class FxThreadAgent { // Scoped to this application's own classes: matching every loaded type (JDK, Spring, // JavaFX, every library) would run matcherFor's reflection over each one for no benefit, // since nothing outside this codebase ever carries @FxThread. - .type(nameStartsWith("org.icroco.pholio.")) + // Classes FxThreadPlugin already wove at build time are skipped: only those an IDE has + // recompiled since (so without the marker) are woven here. + .type(nameStartsWith("org.icroco.pholio.").and(not(isAnnotatedWith(FxThreadWoven.class)))) .transform((builder, typeDescription, classLoader, module, protectionDomain) -> builder.visit(Advice.to(FxThreadInterceptor.class).on(matcherFor(typeDescription)))) .installOnByteBuddyAgent(); - log.debug("@FxThread instrumentation installed"); + log.info("@FxThread load-time weaving installed ({}): classes run from a directory may have been " + + "recompiled without build-time weaving", fromDirectory ? "class directory" : "forced"); + } + + /** + * Whether this application's classes come from a directory ({@code target/classes}, an IDE run) rather + * than a jar — a nested {@code jar:} URL in the repackaged jar, a plain {@code .jar} file once extracted. + */ + private static boolean loadedFromDirectory() { + try { + var source = FxThreadAgent.class.getProtectionDomain().getCodeSource(); + if (source == null || source.getLocation() == null || !"file".equals(source.getLocation().getProtocol())) { + return false; + } + return Files.isDirectory(Path.of(source.getLocation().toURI())); + } + catch (URISyntaxException | RuntimeException e) { + return true; // cannot tell: weave, the safe choice + } } /** @@ -79,7 +115,7 @@ public final class FxThreadAgent { * declared by a class that carries {@link FxThread} itself — in which case every non-abstract, * non-static, non-constructor method of that class is woven, not just some of them. */ - private static ElementMatcher.Junction matcherFor(TypeDescription typeDescription) { + static ElementMatcher.Junction matcherFor(TypeDescription typeDescription) { boolean classAnnotated = typeDescription.getDeclaredAnnotations().isAnnotationPresent(FxThread.class); return classAnnotated ? not(isConstructor()).and(not(isStatic())).and(not(isAbstract())).and(returns(void.class)) diff --git a/src/main/java/org/icroco/pholio/ui/common/FxThreadPlugin.java b/src/main/java/org/icroco/pholio/ui/common/FxThreadPlugin.java new file mode 100644 index 0000000..3e1f0d8 --- /dev/null +++ b/src/main/java/org/icroco/pholio/ui/common/FxThreadPlugin.java @@ -0,0 +1,49 @@ +package org.icroco.pholio.ui.common; + +import net.bytebuddy.asm.Advice; +import net.bytebuddy.build.Plugin; +import net.bytebuddy.description.annotation.AnnotationDescription; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.dynamic.DynamicType; + +/** + * Build-time weaving of {@link FxThread}: run by {@code byte-buddy-maven-plugin} over {@code target/classes} + * at {@code process-classes} (see {@code pom.xml}), after javac, Lombok and ErrorProne are done. + * + *

Applies exactly what {@link FxThreadAgent} applies at load time — the same inlined + * {@link FxThreadInterceptor} {@link Advice}, on the same methods ({@link FxThreadAgent#matcherFor}) — and + * marks each woven class {@link FxThreadWoven}. A Maven-built jar is therefore fully woven and starts with + * no agent at all; the agent only steps in for classes a compiler other than Maven's has produced (an IDE's + * incremental build), which is what made build-time weaving alone unsafe before — see {@link FxThreadAgent}. + * + *

Only classes that actually use {@link FxThread}, and are not already woven, are touched: every other + * class file is left byte-for-byte as javac wrote it. + */ +public class FxThreadPlugin implements Plugin { + + @Override + public boolean matches(TypeDescription target) { + return target.getName().startsWith("org.icroco.pholio.") + && !target.getDeclaredAnnotations().isAnnotationPresent(FxThreadWoven.class) + && usesFxThread(target); + } + + @Override + public DynamicType.Builder apply(DynamicType.Builder builder, TypeDescription typeDescription, + ClassFileLocator classFileLocator) { + return builder.visit(Advice.to(FxThreadInterceptor.class).on(FxThreadAgent.matcherFor(typeDescription))) + .annotateType(AnnotationDescription.Builder.ofType(FxThreadWoven.class).build()); + } + + private static boolean usesFxThread(TypeDescription type) { + return type.getDeclaredAnnotations().isAnnotationPresent(FxThread.class) + || type.getDeclaredMethods().stream() + .anyMatch(method -> method.getDeclaredAnnotations().isAnnotationPresent(FxThread.class)); + } + + @Override + public void close() { + } +} diff --git a/src/main/java/org/icroco/pholio/ui/common/FxThreadWoven.java b/src/main/java/org/icroco/pholio/ui/common/FxThreadWoven.java new file mode 100644 index 0000000..37d4eb5 --- /dev/null +++ b/src/main/java/org/icroco/pholio/ui/common/FxThreadWoven.java @@ -0,0 +1,20 @@ +package org.icroco.pholio.ui.common; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Added by {@link FxThreadPlugin} to every class it weaves at build time — never written by hand. + * + *

Tells {@link FxThreadAgent} a class already carries its {@link FxThread} advice, so the agent, when it + * runs at all (a run from class directories, typically the IDE), only weaves the classes an incremental + * compiler has recompiled since — which lack the marker — and never weaves a class twice. The Maven plugin + * skips marked classes the same way, so re-running {@code process-classes} over already-woven output is a + * no-op. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface FxThreadWoven { +}